diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..8a5ddda17 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "otto-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:win"], + "port": 8081 + }, + { + "name": "otto-agent", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:win:agent"], + "port": 8095 + }, + { + "name": "website", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:website"], + "port": 4300 + }, + { + "name": "archdocs", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "archdocs:serve"], + "port": 4400 + } + ] +} diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 000000000..4bb2f45e9 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,117 @@ +--- +name: release +description: Cut a release of Otto — the one streamlined runbook. Default is a stable patch. Use when the user says "release", "release otto", "ship", "ship stable", "cut a release", "new version", "release:patch", "promote", "release beta", or "/release". Handles stable-patch (default), beta, and promote in one place. +user-invocable: true +--- + +# Release + +The self-contained release runbook. **Follow this skill directly — do not re-read `docs/release.md` for a normal release.** `docs/release.md` is the exhaustive reference; come back to it only for the edge cases flagged below (rollout tuning, retrying a failed build). This keeps a release token-light. + +**Default = stable patch.** "Release", "ship", "ship stable", "release otto" all mean a patch bump from the previous stable. Only run the beta path when the user explicitly says "beta". Never bump minor/major to trigger or retry a build — that needs an explicit "minor"/"major" from the user. + +## The two-step safety gate (applies always) + +1. **Preparation** (agent does this — local, reversible): pre-flight checks, draft changelog, sanity check. Show findings, wait. +2. **Go-ahead** (only after the user says "go ahead" / "ship it"): commit the changelog, run the release command. + +Invoking this skill is intent to _start_, not authorization to publish. If the user asks for a **preview**, show the prospective changelog and answer questions — do **not** commit, tag, or run any `release:*` command until they explicitly authorize. Last-minute changes always need approval. A sanity-check finding is information, not a directive — surface it, the user decides. + +--- + +## Stable patch (the 95% path) + +### 1. Pre-flight (agent, reversible) + +Run from a clean tree on `main`, on the exact commit to be released: + +```bash +npm run format && npm run lint && npm run typecheck # all green; commit any format churn on its own +npm run acp:version-drift:check # if drift is intentional, say so; else: npm run acp:version-drift:update && commit +npm run release:prepare # run once alone to absorb any package-lock churn into a normal commit +``` + +Why `release:prepare` first: it runs `npm install --workspaces`, which can churn `package-lock.json`. `version:all:*` aborts on a dirty tree, and the pre-commit hook rejects a lockfile-only commit. Absorbing the churn now avoids a mid-release mess. + +### 2. Draft the changelog (agent writes it — never hand it off) + +- Heading is **strict**: `## X.Y.Z - YYYY-MM-DD` — no `v`, no extra text. A malformed heading breaks Release Notes Sync. +- Draft from the `v..HEAD` diff. Covers the **full** delta from the previous stable. +- **User-facing voice**, not implementation. Describe what changed in the app, not the code. No component/module names, no "remount / virtualized / debounced / memoized". +- One sentence per bullet, no trailing periods, one line each. Split any bullet that chains changes with "and"/commas/em-dash. Collapse intra-release fixes (if a feature was added _and_ fixed this release, list only the working feature). +- Order by user impact: features → quality-of-life → internal-with-user-benefit. +- **Attribution** — credit external commit _authors_ (not the PR opener), skip [@boudra](https://github.com/boudra). Link PRs to `Draek2077/otto-code`: + ```bash + git log --format='%H %s' v..HEAD | grep -E '\(#[0-9]+\)$' # find PR numbers + gh pr view N --repo Draek2077/otto-code --json commits --jq '[.commits[].authors[].login] | unique | .[]' # authors + ``` + Format: `([#123](https://github.com/Draek2077/otto-code/pull/123) by [@user](https://github.com/user))`. + +Show the drafted entry to the user and **wait for approval**. Do not commit it yet. + +### 3. Pre-release sanity check (stable only) + +Review `git diff ..HEAD` as a last line of defence. Focus on: protocol breaks (WebSocket / agent lifecycle / server↔client), **old app client vs. new daemon** back-compat (users update daemon first, keep the old app), and regressions. Surface anything risky; the user decides. + +### 4. Go-ahead → commit + release + +Only after explicit go-ahead: + +```bash +# Commit the approved changelog on its OWN commit — no code bundled in. +git commit -m "docs(changelog): add X.Y.Z release notes" + +# Confirm prerequisites, then cut it. +npm whoami # must be an otto-code npm org member +npm run release:patch # release:check → version:all:patch (bump+commit+tag) → release:publish → release:push +``` + +`release:patch` bumps every workspace, publishes the six `@otto-code/*` packages, and pushes HEAD + tag. The tag push triggers CI (see step 5). + +**If publish fails mid-chain** (auth expired, registry hiccup): the version commit + tag exist but are unpushed. Don't re-run the full chain — resume with `npm run release:publish` then `npm run release:push`. + +### 5. Done — `release:push` is the last step + +**Do NOT watch the builds.** No background polling loop, no `gh run list` heartbeat, no scheduled re-check, no "I'll report back when they settle." The user watches CI themselves and has ruled the monitoring a waste of tokens. Report what shipped and end the turn. + +The `v*` tag push triggers, in **your** repo: `Desktop Release`, `Android APK Release`, `Android Play Release` (AAB → Play internal track), `Docker`, `Deploy App` (web app), `Release Notes Sync`. `Deploy Website` runs when the GitHub release publishes (stable only). + +- **`gh` defaults to upstream Paseo here — always pass `--repo Draek2077/otto-code`** — for the one-off checks below, or when the user asks about a specific failure later. +- macOS desktop jobs **run and produce unsigned artifacts** (they no longer skip for want of Apple signing — changed as of 0.6.6). A red mac job is a **real failure**, not an expected skip. Unsigned means a Gatekeeper warning on first open; that is the known trade, not a defect. +- Spot-check once, if at all: `npm view @otto-code/cli version` shows the new version on `latest`. + +Stable rollout is a 36h staged ramp by default; nothing extra needed. To admit everyone immediately or tune the ramp, see **`docs/release.md` → "Staged rollout"**. + +--- + +## Beta path (only when the user says "beta") + +Betas are fast release candidates on the `beta` channel: npm publishes on the `beta` dist-tag only, the website download target does not move, and **the sanity check is skipped** (the beta is the smoke test). + +```bash +npm run release:beta:patch # → X.Y.Z-beta.1: check, bump, publish beta dist-tag, push tag +# ...smoke desktop + APK prerelease assets from GitHub Releases... +npm run release:beta:next # optional: cut beta.2, beta.3, ... +npm run release:promote # promote X.Y.Z-beta.N → stable X.Y.Z (fresh v* tag) +``` + +Beta changelog: keep **one** in-place `## X.Y.Z-beta.N - YYYY-MM-DD` entry that always covers the full previous-stable→HEAD delta. Each beta bumps that same heading; promotion overwrites it in place (heading → `X.Y.Z`, date → promotion day). Never leave a stale `-beta.N` heading or append a new per-beta entry. Verify npm: `npm view @otto-code/cli dist-tags` (version under `beta`, not `latest`). + +Promotion runs the **stable** completion checklist, including the sanity check. + +--- + +## Hard rules + +- **Never bump minor/major to fix or retrigger a build.** Build/CI failures are fixed on the current version. To rebuild a target, push a retry tag — see `docs/release.md` → "Fixing a failed release build" (Docker-only retries use `docker.yml` dispatch, never a re-pushed `v*` tag). +- **No code in the changelog commit or the release commit.** Code shims are their own reviewed commit. +- **"Stable" means stable** — don't offer a beta first when the user said stable. +- **Releases are always patch** unless the user explicitly says "minor"/"major". + +## When to open `docs/release.md` + +Only for these — everything else is above: + +- Staged rollout tuning: instant-admit, `desktop-rollout.yml`, custom ramps, releasing during an active rollout. +- Fixing a failed build: retry-tag patterns (`desktop-vX.Y.Z`, `android-vX.Y.Z`, per-platform), Docker dispatch, why `workflow_dispatch` won't pick up tagged code fixes. +- Watching EAS builds from the terminal; the full completion checklists. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..36e0b0053 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +.git +.debug.conversations +.debug +.dev +.playwright-mcp +**/.playwright-mcp +.otto +**/.otto-provider-history +.plans +.tasks +.valknut +.claude/settings.local.json +**/.claude/settings.local.json +.claude/scheduled_tasks.lock +.claude/worktrees +.wrangler +**/.wrangler +**/.tanstack +PLAN.md +valknut-report.html +valknut-report.json +.env* +**/.env* +.dev.vars +**/.dev.vars +*.pem +**/*.pem +**/.secrets +**/node_modules +**/dist +**/build +**/.cache +**/.expo +**/test-results +**/*.tsbuildinfo +artifacts +packages/app/android +packages/desktop/release +plan.*.log +*.log +CLAUDE.local.md diff --git a/.easignore b/.easignore new file mode 100644 index 000000000..bc1071b64 --- /dev/null +++ b/.easignore @@ -0,0 +1,64 @@ +# .easignore — controls what EAS Build uploads in the project archive. +# +# Why this file exists: +# Without it, EAS falls back to .gitignore, but it only applies the ROOT +# .gitignore — it does NOT honor nested per-package .gitignore files. The +# generated native project is ignored in packages/app/.gitignore (`/android`, +# `/ios`), so EAS never excludes it and sweeps the ~1.3 GB Gradle + CMake +# build tree (packages/app/android/app/{build,.cxx}) into the archive, +# inflating it to ~1.8 GB. EAS regenerates the native project with +# `expo prebuild` on the build server, so none of it needs uploading. +# +# When this file is present, EAS uses ONLY these patterns and ignores +# .gitignore entirely — so everything git normally excludes (node_modules, +# build output) must be re-listed here. +# +# IMPORTANT: native-folder excludes are ANCHORED to the generated app project +# only. Do NOT use a bare `android/` or `ios/` pattern — real native module +# sources live in packages/expo-two-way-audio/{android,ios} and +# packages/app/modules/otto-hardware-keyboard/ios and must be uploaded. + +# Dependencies — reinstalled on the build server from the lockfile +node_modules/ + +# Generated native projects — EAS runs `expo prebuild` itself +/packages/app/android/ +/packages/app/ios/ + +# Build outputs, caches, generated declarations +dist/ +build/ +out/ +.next/ +.expo/ +.gradle/ +.cxx/ +.kotlin/ +*.tsbuildinfo +coverage/ +test-results/ +**/test-results/ +.vitest-screenshots/ + +# Otto dev home + local tooling / editor state (not part of the app) +.dev/ +.git/ +.github/ +.claude/ +.agents/ +.idea/ +.vscode/ +graphify-out/ + +# Packages the mobile app does not depend on +packages/desktop/ +packages/website/ + +# Logs and local env +*.log +.env +**/.env.local +**/.env.test.local + +# OS cruft +.DS_Store diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6735efafc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalize all text files to LF so Windows checkouts (core.autocrlf=true) +# match what CI checks: oxfmt flags CRLF working-tree files, making local +# `npm run format:check` fail on files that are clean in the index. +* text=auto eol=lf + +# Windows scripts that must keep CRLF endings. +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..706833347 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [boudra] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..e2f8a8571 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,123 @@ +name: Bug report +description: Something is broken or doesn't behave the way it should. +title: "bug: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + This is a one-person project run in spare time, so Issues don't always get a same-day reply. Filing one here is still the best way to reach me — it's the only place I track things. + + Before opening, please: + + - search existing issues for the same symptom + - try to reproduce on the latest version + - if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail. + + - type: textarea + id: description + attributes: + label: What's broken + description: What happened, and what did you expect to happen instead? + placeholder: | + I tried to X, expected Y, got Z. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so. + placeholder: | + 1. Open the desktop app + 2. Pair a daemon + 3. Click X + 4. ... + validations: + required: true + + - type: dropdown + id: surface + attributes: + label: Where did this happen + description: The surface you saw the bug on. Pick the closest match. + options: + - iOS app + - Android app + - Web (browser) + - Desktop (Electron) + - CLI + - Daemon + - Other + validations: + required: true + + - type: input + id: otto-version + attributes: + label: Otto version + description: Settings → About in the app, or `otto --version` from the CLI. + placeholder: "0.1.71" + validations: + required: true + + - type: input + id: os-version + attributes: + label: OS version + description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web. + placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04" + + - type: dropdown + id: provider + attributes: + label: Agent provider + description: If the bug involves a specific agent provider, pick which one. + options: + - Not relevant + - Claude Code + - Codex + - OpenCode + - Custom provider + + - type: textarea + id: provider-details + attributes: + label: Provider configuration + description: | + If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends. + placeholder: | + Claude Code v1.2.3 with Anthropic API + Codex CLI v0.5.0 with OpenAI API + OpenCode v0.3.1 + (or paste the relevant section of ~/.otto/config.json for custom providers) + + - type: textarea + id: logs + attributes: + label: Logs + description: | + Paste relevant log output. Strongly preferred for crashes and daemon issues. + + - **Daemon log:** `~/.otto/daemon.log` (override with `$OTTO_HOME`) + - **Electron log (macOS):** `~/Library/Logs/Otto/main.log` + - **Electron log (Windows):** `%APPDATA%\Otto\logs\main.log` + - **Electron log (Linux):** `~/.config/Otto/logs/main.log` + + Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time. + render: text + + - type: textarea + id: screenshots + attributes: + label: Screenshots or video + description: | + **Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation. + + - type: markdown + attributes: + value: | + --- + + **A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..57e4f0e32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discussions + url: https://github.com/Draek2077/otto-code/discussions + about: Questions, ideas, or anything that's better as a conversation than a bug report. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..31ad12008 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,43 @@ +name: Feature request +description: Propose a new feature or a change to existing behavior. +title: "feat: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Otto is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain. + + Big ideas are better opened as a [GitHub Discussion](https://github.com/Draek2077/otto-code/discussions) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code. + + - type: checkboxes + id: prior-search + attributes: + label: Prior search + options: + - label: I searched existing issues and discussions, and this isn't already proposed. + required: true + + - type: textarea + id: problem + attributes: + label: What's the problem + description: What are you actually trying to do, and why is the current behavior in the way? + placeholder: | + When I'm doing X, I want to Y, but Otto currently Z. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would solve it + description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you considered + description: Optional. What else did you try, and why doesn't it work? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..7d5039aed --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ + + +### Linked issue + +Closes # + + + +### Type of change + +- [ ] Bug fix +- [ ] New feature (with prior issue + design alignment) +- [ ] Refactor / code improvement +- [ ] Docs + +### What does this PR do + + + +### How did you verify it + + + +### Checklist + +- [ ] One focused change. Unrelated cleanups split out. +- [ ] `npm run typecheck` passes +- [ ] `npm run lint` passes +- [ ] `npm run format` ran (Biome) +- [ ] UI changes include screenshots or video for every affected platform +- [ ] Tests added or updated where it made sense diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..92b9fe6b2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 + +# Why this exists: the actions in .github/workflows/ drifted three majors behind before anyone +# noticed, and the only signal was a Node 20 deprecation annotation buried in every run's log. +# Grouping keeps that from turning into fourteen separate PRs a week. +# +# npm is deliberately absent. Routine version drift across 10+ workspaces would bury the signal, +# and the Electron/Expo/React Native pins break in ways CI does not always catch. npm is covered +# by Dependabot *security* updates instead, which are a repository setting rather than a block +# here (Settings > Code security > Dependabot). Adding a `package-ecosystem: npm` entry below +# would turn that quiet advisory feed into full version-bump PRs, which is not what we want. +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(ci)" + ignore: + # gradle/actions v6 moves caching into gradle-actions-caching, a proprietary + # component under Gradle's commercial Terms of Use. We hold at v5, which is + # Node 24 native and MIT. Revisit only as a deliberate licensing decision. + - dependency-name: gradle/actions + update-types: + - version-update:semver-major + groups: + # Majors land on their own so the release notes actually get read; everything else + # rides in one PR that CI either passes or doesn't. + actions-minor: + patterns: + - "*" + update-types: + - minor + - patch diff --git a/.github/workflows/android-apk-release.yml b/.github/workflows/android-apk-release.yml new file mode 100644 index 000000000..bfe865b16 --- /dev/null +++ b/.github/workflows/android-apk-release.yml @@ -0,0 +1,242 @@ +name: Android APK Release + +# Builds the sideloadable release APK ON THIS RUNNER (expo prebuild + gradle), +# not on EAS, and attaches it to the GitHub Release. +# +# It used to shell out to `eas build --profile production-apk`. EAS's free tier +# allows 15 Android builds a month shared across every Android workflow, and the +# budget was exhausted on 2026-07-12 — 0.5.2 through 0.7.1 shipped with no +# Android artifact at all, and a quota-refused run still burns a versionCode. +# Building here is unlimited and removes the need to ration releases. +# +# This does not affect Play: the AAB goes down android-play-release.yml, and +# Play App Signing already means the store APK and this sideload APK carry +# different signatures. +# +# Requires four repo secrets (the same keystore EAS uses, so this APK installs +# as a drop-in update over an EAS-built Otto — a different key would force +# users to uninstall first): +# - ANDROID_KEYSTORE_BASE64 base64 of the .jks from `eas credentials` +# - ANDROID_KEYSTORE_PASSWORD keystore password +# - ANDROID_KEY_ALIAS key alias +# - ANDROID_KEY_PASSWORD key password +# Without them the signing step fails loudly rather than shipping an unsigned or +# debug-signed APK. + +on: + push: + tags: + - "v*" + - "android-v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to build (e.g. v0.1.0)" + required: true + type: string + +concurrency: + group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +env: + SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + +jobs: + # Its own job purely so the shared lock below is held for seconds instead of + # for the whole APK build. Leaving this as a step inside publish-android-apk + # would make Desktop Release wait out a fifteen-minute gradle run to create + # the release it is about to publish into. + ensure-release: + # Shared with desktop-release.yml and release-notes-sync.yml. All three + # ensure this release exists, and a `v*` tag starts all three at once. + # GitHub does NOT enforce tag_name uniqueness on create, so two concurrent + # `gh release create` calls both succeed and leave two releases on one tag; + # from then on every PATCH fails with 422 already_exists, which is how a + # duplicate created here surfaces as a Release Notes Sync failure two + # workflows later (0.7.3, two creates 82ms apart). Uniqueness IS enforced on + # update, so the damage is only visible after the fact. Concurrency group + # names are repo-wide, not workflow-scoped, so an identical string across + # workflows serializes these jobs and the loser then takes its existing + # "already exists, skipping" path. Must stay byte-identical in all three. + concurrency: + group: otto-release-ensure-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + permissions: + contents: write + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: scripts + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Resolve release tag + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Ensure GitHub release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + exit 0 + fi + + release_args=( + release create "$RELEASE_TAG" + --repo "${{ github.repository }}" + --title "Otto $RELEASE_TAG" + --notes "" + ) + + if [[ "$IS_PRERELEASE" == "true" ]]; then + release_args+=(--prerelease) + fi + + if ! gh "${release_args[@]}"; then + echo "Release creation raced with another workflow; continuing." + fi + + publish-android-apk: + needs: ensure-release + permissions: + contents: write + packages: read + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Resolve release tag + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v5 + + - name: Install JS dependencies + run: npm ci + + # What EAS ran as eas-build-post-install. The webview bundles are generated + # sources: the committed copies go stale, and prebuild does not rebuild them. + - name: Build workspace deps and webview bundles + run: npm run eas-build-post-install --workspace=@otto-code/app + + - name: Prebuild the Android project + env: + APP_VARIANT: production + run: npx expo prebuild --platform android --clean --non-interactive + working-directory: packages/app + + # `-Pandroid.injected.version.code` is a NO-OP for assembleRelease under + # AGP 8 — it silently yields versionCode 1, which Android refuses to + # install over an existing Otto. Write it into the generated build.gradle + # instead. The file is gitignored (prebuild regenerates it), so nothing + # tracked changes here. + # + # versionCode is derived from the version rather than a remote counter: + # major*10000 + minor*100 + patch, so 0.7.2 -> 702. Monotonic across + # releases and reproducible from the tag alone, unlike EAS's counter which + # drifted to 50+ because refused builds still incremented it. + - name: Set versionCode from the release version + shell: bash + run: | + set -euo pipefail + version="${RELEASE_VERSION%%-*}" + IFS='.' read -r major minor patch <<< "$version" + version_code=$(( major * 10000 + minor * 100 + patch )) + echo "Using versionCode $version_code for $RELEASE_VERSION" + + gradle_file=packages/app/android/app/build.gradle + perl -pi -e "s/versionCode\s+\d+/versionCode $version_code/" "$gradle_file" + grep -n "versionCode" "$gradle_file" + + - name: Decode signing keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + shell: bash + run: | + set -euo pipefail + if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then + echo "ANDROID_KEYSTORE_BASE64 is not set. Add the release keystore secrets" >&2 + echo "before tagging; see the header of this workflow." >&2 + exit 1 + fi + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + + # Mirrors the eas.json production-apk gradleCommand exactly, lint tasks and + # all. Signing properties are injected (unlike the version code, these do + # work) and read straight from secrets, so nothing is written to disk or + # echoed. + - name: Assemble the release APK + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + shell: bash + working-directory: packages/app/android + run: | + set -euo pipefail + ./gradlew :app:assembleRelease \ + --no-parallel --max-workers=1 \ + -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease \ + -x generateReleaseLintModel -x generateReleaseLintVitalModel \ + -Pandroid.injected.signing.store.file="$RUNNER_TEMP/release.jks" \ + -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" + + - name: Collect and verify the APK + id: artifact + shell: bash + run: | + set -euo pipefail + apk="$(find packages/app/android/app/build/outputs/apk/release -name '*.apk' | head -n 1)" + if [ -z "$apk" ]; then + echo "No APK produced." >&2 + exit 1 + fi + + # Fail loudly on a debug-signed APK: it would install for nobody who + # already has Otto, and the failure is otherwise silent until a user + # tries to update. + build_tools="$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -n 1)" + "$build_tools/apksigner" verify --print-certs "$apk" | tee "$RUNNER_TEMP/signer.txt" + if grep -qi "CN=Android Debug" "$RUNNER_TEMP/signer.txt"; then + echo "APK is debug-signed; refusing to publish." >&2 + exit 1 + fi + "$build_tools/aapt2" dump badging "$apk" | head -n 1 + + asset_name="otto-${RELEASE_TAG}-android.apk" + asset_path="$RUNNER_TEMP/$asset_name" + cp "$apk" "$asset_path" + + echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT" + echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT" + + - name: Upload APK to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}" diff --git a/.github/workflows/android-play-release.yml b/.github/workflows/android-play-release.yml new file mode 100644 index 000000000..52a94b228 --- /dev/null +++ b/.github/workflows/android-play-release.yml @@ -0,0 +1,141 @@ +name: Android Play Release + +# Builds an Android App Bundle (AAB) via EAS and submits it to the Google Play +# **internal testing** track on a release tag. This is the automated replacement +# for hand-downloading the AAB and uploading it in the Play Console. +# +# Kept separate from android-apk-release.yml (which builds a sideloadable APK and +# attaches it to the GitHub Release) so the two can be retried independently: a +# failed Play submission does not require rebuilding the sideload APK, and a +# retry tag `android-play-vX.Y.Z` rebuilds/submits only this path. +# +# Retrying a failed *submit* without rebuilding: run this workflow via +# workflow_dispatch with `build_id` set to an already-finished EAS build id. The +# build step is skipped and that build is submitted as-is (e.g. after enabling a +# missing Google API or fixing a Play permission). +# +# Requires two repo secrets: +# - EXPO_TOKEN (already set; authenticates the EAS build) +# - GOOGLE_SERVICE_ACCOUNT_KEY (full JSON of a Google Play service-account +# key with the "Release to testing tracks" +# permission for me.ottocode.mobile) +# +# Deliberately NOT triggered by `v*` (removed 2026-07-27). EAS's free tier allows +# 15 Android builds a month, and firing this on every release tag spent half of +# that budget on a path that has never gone green end-to-end: 20 runs, 20 +# failures, and 0.5.1 — the only build to reach Play — was uploaded by hand. The +# APK workflow was starved as a direct result and shipped no Android artifact at +# all for 0.5.2 through 0.7.1. +# +# Note that a quota-refused run is not free: EAS increments the remote versionCode +# *before* it checks the budget, so every failed run burns a versionCode (that is +# why the counter reads 50 while the last real build was 0.5.1). +# +# Restore the `v*` trigger once a submit actually succeeds from CI. + +on: + push: + tags: + - "android-play-v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to check out (e.g. v0.5.1 or android-play-v0.5.1)" + required: true + type: string + build_id: + description: "Existing EAS build id to submit as-is (skips the build). Leave empty to build from the tag." + required: false + type: string + default: "" + +concurrency: + group: android-play-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + submit-play-internal: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install JS dependencies + run: npm ci + + - name: Setup Expo and EAS + uses: expo/expo-github-action@v9 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Write Google Play service account key + env: + GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} + shell: bash + run: | + set -euo pipefail + if [ -z "${GOOGLE_SERVICE_ACCOUNT_KEY:-}" ]; then + echo "GOOGLE_SERVICE_ACCOUNT_KEY secret is not set — cannot submit to Play." >&2 + exit 1 + fi + # eas.json's submit.internal profile reads ./google-service-account.json + # (relative to packages/app). Written from a secret so it never lands in git. + printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > packages/app/google-service-account.json + + - name: Build Android App Bundle on EAS + id: eas_build + # Skip the build when a caller supplies an existing build id to submit + # as-is (retry-a-failed-submit path). Empty for tag pushes, so they build. + if: ${{ github.event.inputs.build_id == '' }} + shell: bash + run: | + set -euo pipefail + cd packages/app + + build_json="$(npx eas build --platform android --profile production --non-interactive --wait --json)" + echo "$build_json" > "$RUNNER_TEMP/eas-build.json" + + build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")" + if [ -z "$build_id" ]; then + echo "Failed to determine EAS build ID." >&2 + cat "$RUNNER_TEMP/eas-build.json" + exit 1 + fi + + echo "build_id=$build_id" >> "$GITHUB_OUTPUT" + + - name: Submit AAB to Play internal track + shell: bash + env: + INPUT_BUILD_ID: ${{ github.event.inputs.build_id }} + BUILT_BUILD_ID: ${{ steps.eas_build.outputs.build_id }} + run: | + set -euo pipefail + cd packages/app + # Prefer a caller-supplied build id (submit-only retry); otherwise use + # the id from the build step above. + submit_id="${INPUT_BUILD_ID:-}" + if [ -z "$submit_id" ]; then submit_id="${BUILT_BUILD_ID:-}"; fi + if [ -z "$submit_id" ]; then + echo "No EAS build id to submit (build step skipped and no build_id input)." >&2 + exit 1 + fi + echo "Submitting EAS build $submit_id to Play internal track" + npx eas submit --platform android --profile internal --id "$submit_id" --non-interactive + + - name: Clean up service account key + if: always() + shell: bash + run: rm -f packages/app/google-service-account.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a8162f84..53c7f5613 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,33 +4,396 @@ on: push: branches: [main] pull_request: + branches: [main] + merge_group: + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # CI does not use the CUDA execution provider, and the onnxruntime-node + # postinstall download from NuGet is large enough to make npm ci flaky. + ONNXRUNTIME_NODE_INSTALL: skip jobs: - test: + format: + runs-on: ubuntu-latest + env: + # This job never executes Electron. Skipping the hosted binary avoids + # unrelated npm ci failures when Electron's CDN returns 504. + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Check formatting + run: npx oxfmt --check . + + lint: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + # Rides the lint job rather than owning one: the check is pure file + # analysis over the coverage matrix and packages/app/e2e, needs no + # dependencies, and finishes in well under a second — a dedicated job + # would spend a minute of npm ci to run it. Placed before `npm ci` so it + # fails fast and still reports drift when installation is broken. + # It lives in CI rather than the pre-commit hook on purpose: the hook is + # shared by every parallel session in a working tree, and this guard has + # to compare the whole spec directory against the whole matrix, so it + # could not be scoped to staged files anyway. + - name: E2E coverage matrix drift + run: node scripts/e2e-coverage-check.mjs + + - name: Install dependencies + run: npm ci + + - name: Lint lockfile + run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity + + - name: Verify dependency signatures + run: npm audit signatures + + - name: Lint + run: npm run lint + + typecheck: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + - name: Build server stack + run: npm run build:server + + - name: Typecheck all packages + run: npm run typecheck + + - name: Verify public package contents + run: | + npm pack --dry-run --ignore-scripts --workspace=@otto-code/protocol + npm pack --dry-run --ignore-scripts --workspace=@otto-code/client + npm pack --dry-run --ignore-scripts --workspace=@otto-code/server + + # One step, one payload. The sidecar is framework-dependent IL, so there is no per-RID matrix + # and no runtime restore — building on Linux produces the artifact that runs on all three + # platforms. The job asserts that, rather than trusting it: it publishes, then drives the real + # NDJSON protocol against a fixture solution. + dotnet-probe: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: "8.0.x" + + - uses: actions/setup-node@v7 + with: + node-version: "24" + + - name: Build the solution sidecar + run: npm run build:dotnet-probe:required - - uses: pnpm/action-setup@v4 + - name: Verify the payload answers the protocol + run: node scripts/verify-dotnet-probe.mjs + + - uses: actions/upload-artifact@v7 + with: + name: dotnet-probe + path: packages/dotnet-probe/dist/ + retention-days: 7 + + server-tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + name: server-tests (${{ matrix.os }}) + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 with: - version: 10 + fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: - node-version: 22 - cache: pnpm + node-version: "24" + cache: "npm" + + - name: Fetch origin/main (worktree tests) + run: git fetch --no-tags origin main:refs/remotes/origin/main - name: Install dependencies - run: pnpm install --frozen-lockfile + run: npm ci + - name: Install agent CLIs for provider tests + run: npm install -g @anthropic-ai/claude-code opencode-ai + + - name: Build server dependencies + run: npm run build:server-deps + + - name: Run server tests + run: npm run test --workspace=@otto-code/server + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + + desktop-tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies with Electron retry + if: runner.os != 'Windows' + run: | + for attempt in 1 2 3; do + if npm ci; then + exit 0 + else + exit_code=$? + fi + if [ "$attempt" -eq 3 ]; then + exit $exit_code + fi + sleep $((attempt * 20)) + done - - name: Root tests (scripts, app) - run: pnpm test + - name: Install dependencies with Electron retry + if: runner.os == 'Windows' + shell: pwsh + run: | + for ($attempt = 1; $attempt -le 3; $attempt++) { + npm ci + if ($LASTEXITCODE -eq 0) { + exit 0 + } + if ($attempt -eq 3) { + exit $LASTEXITCODE + } + Start-Sleep -Seconds (20 * $attempt) + } + - name: Build server stack + run: npm run build:server - - name: Extension tests - run: pnpm --dir extension test + - name: Run desktop tests + run: npm run test --workspace=@otto-code/desktop + + app-tests: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies with retry + run: | + for attempt in 1 2 3; do + if npm ci; then + exit 0 + else + exit_code=$? + fi + if [ "$attempt" -eq 3 ]; then + exit $exit_code + fi + sleep $((attempt * 20)) + done + - name: Install Playwright browsers + timeout-minutes: 10 + run: npx playwright install chromium + + - name: Build app dependencies + run: npm run build:app-deps + + - name: Run app unit tests + run: npm run test --workspace=@otto-code/app + + sdk-tests: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + - name: Build client dependencies + run: npm run build:client + + - name: Run protocol tests + run: npm run test --workspace=@otto-code/protocol + + - name: Run client tests + run: npm run test --workspace=@otto-code/client + + - name: Typecheck client examples + run: npm run typecheck:examples --workspace=@otto-code/client + + playwright: + strategy: + fail-fast: false + matrix: + include: + - { label: "shard 1/4", shard: 1, desktop: false } + - { label: "shard 2/4", shard: 2, desktop: false } + - { label: "shard 3/4", shard: 3, desktop: false } + - { label: "shard 4/4", shard: 4, desktop: false } + - { label: "desktop overlay", shard: "desktop", desktop: true } + name: playwright (${{ matrix.label }}) + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies with retry + run: | + for attempt in 1 2 3; do + if npm ci; then + exit 0 + else + exit_code=$? + fi + if [ "$attempt" -eq 3 ]; then + exit $exit_code + fi + sleep $((attempt * 20)) + done + - name: Install Playwright browsers + timeout-minutes: 10 + run: npx playwright install chromium + + - name: Build app dependencies + run: npm run build:app-deps + + - name: Build server stack + run: npm run build:server + + - name: Install agent CLIs for provider tests + if: ${{ !matrix.desktop }} + run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai + + - name: Run Playwright E2E tests + if: ${{ !matrix.desktop }} + run: npm run test:e2e --workspace=@otto-code/app -- --shard=${{ matrix.shard }}/4 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + - name: Run desktop-overlay Playwright tests + if: ${{ matrix.desktop }} + run: npm run test:e2e:desktop --workspace=@otto-code/app + + - name: Upload test artifacts + uses: actions/upload-artifact@v7 + if: failure() + with: + name: playwright-results-${{ matrix.shard }} + path: | + packages/app/test-results/ + packages/app/playwright-report/ + retention-days: 7 + + relay-tests: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build relay + run: npm run build:relay + + - name: Run relay tests + run: npm run test --workspace=@otto-code/relay + + cli-tests: + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + runs-on: ubuntu-latest + name: cli-tests (shard ${{ matrix.shard }}/3) + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci - - name: Extension typecheck - run: pnpm --filter agent-flow run lint + - name: Install agent CLIs for provider tests + run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - - name: Web typecheck - run: pnpm --dir web exec tsc --noEmit + - name: Run CLI tests + run: npm run test --workspace=@otto-code/cli + env: + OTTO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + OTTO_DICTATION_ENABLED: "0" + OTTO_VOICE_MODE_ENABLED: "0" + OTTO_CLI_TEST_SHARD: ${{ matrix.shard }} + OTTO_CLI_TEST_SHARD_TOTAL: "3" diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml new file mode 100644 index 000000000..41178ff13 --- /dev/null +++ b/.github/workflows/deploy-app.yml @@ -0,0 +1,49 @@ +name: Deploy App + +on: + push: + tags: + - "v*" + - "!v*-beta.*" + - "app-v*" + - "!app-v*-beta.*" + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build app dependencies + run: npm run build:app-deps + + - name: Typecheck + run: npm run typecheck --workspace=@otto-code/app + + # wrangler pages deploy doesn't auto-create the project; create it on first + # run. The create command fails when the project already exists, so tolerate + # that — a real auth/permission problem still surfaces in the deploy step. + # CLOUDFLARE_ACCOUNT_ID is the fork's own Cloudflare account — the same + # account_id as packages/{relay,website}/wrangler.toml. Keep them in sync. + - name: Ensure Cloudflare Pages project exists + run: npx wrangler pages project create otto-app --production-branch main || echo "Project already exists (or creation failed; deploy will surface real errors)" + working-directory: packages/app + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: 3910072adb29ec7ee1bdcc37d532765d + + - name: Build and deploy to Cloudflare Pages + run: npm run deploy:web --workspace=@otto-code/app + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: 3910072adb29ec7ee1bdcc37d532765d diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml new file mode 100644 index 000000000..8fe518e3d --- /dev/null +++ b/.github/workflows/deploy-relay.yml @@ -0,0 +1,33 @@ +name: Deploy Relay + +on: + # Manual-only while relay.otto-code.me bridges traffic to the Fly deployment. + # A release or main push must not redeploy the temporary Cloudflare bridge. + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci --workspace=@otto-code/relay --include-workspace-root + + - name: Typecheck + run: npm run typecheck --workspace=@otto-code/relay + + - name: Deploy worker + # OTTO_RELAY_UPSTREAM is set here, not in wrangler.toml [vars], so the + # Fly cutover bridge only applies to the deployed production worker and + # never leaks into local/e2e `wrangler dev` runs (which would then fail + # to reach the external upstream in CI). See wrangler.toml for context. + run: cd packages/relay && npx wrangler deploy --var OTTO_RELAY_UPSTREAM:https://otto-relay-next.fly.dev + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 000000000..270e9a78c --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,40 @@ +name: Deploy Website + +on: + push: + branches: [main] + paths: + - "CHANGELOG.md" + - "public-docs/**" + - "packages/website/**" + - "package.json" + - "package-lock.json" + - "patches/**" + - ".github/workflows/deploy-website.yml" + release: + types: [published] + workflow_dispatch: + +jobs: + deploy: + if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci --workspace=@otto-code/website --include-workspace-root + + - name: Typecheck + run: npm run typecheck --workspace=@otto-code/website + + - name: Deploy to Cloudflare Workers + run: npm run deploy --workspace=@otto-code/website + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 000000000..87e4c9d93 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,628 @@ +name: Desktop Release + +on: + push: + tags: + - "v*" + - "desktop-v*" + - "desktop-macos-v*" + - "desktop-linux-v*" + - "desktop-windows-v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to build (e.g. v0.1.0)" + required: true + type: string + platform: + description: "Optional desktop platform to build." + required: false + default: "all" + type: choice + options: + - all + - macos + - linux + - windows + checkout_ref: + description: "Optional branch/ref to checkout while using tag for release metadata." + required: false + default: "" + type: string + publish: + description: "Publish built artifacts to GitHub Releases." + required: false + default: "true" + type: choice + options: + - "true" + - "false" + rollout_hours: + description: "Linear rollout duration in hours. Use 0 for instant rollout." + required: false + default: "36" + type: string + +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + +env: + SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }} + SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }} + ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }} + DESKTOP_PACKAGE_PATH: "packages/desktop" + +jobs: + create-release: + if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all' && github.event.inputs.publish != 'false') }} + # Shared with android-apk-release.yml and release-notes-sync.yml. All three + # ensure this release exists, and a `v*` tag starts all three at once. + # GitHub does NOT enforce tag_name uniqueness on create, so two concurrent + # `gh release create` calls both succeed and leave two releases on one tag; + # from then on every PATCH fails with 422 already_exists, which is how a + # duplicate created here surfaces as a Release Notes Sync failure two + # workflows later (0.7.3, two creates 82ms apart). Uniqueness IS enforced on + # update, so the damage is only visible after the fact. Concurrency group + # names are repo-wide, not workflow-scoped, so an identical string across + # workflows serializes these jobs and the loser then takes its existing + # "already exists, skipping" path. Must stay byte-identical in all three. + concurrency: + group: otto-release-ensure-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: scripts + ref: ${{ env.CHECKOUT_REF }} + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Create GitHub release + if: env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists, skipping creation" + else + prerelease_flag="" + if [[ "$IS_PRERELEASE" == "true" ]]; then + prerelease_flag="--prerelease" + fi + gh release create "$RELEASE_TAG" \ + --repo "${{ github.repository }}" \ + --title "Otto $RELEASE_TAG" \ + --notes "" \ + $prerelease_flag || { + echo "Release creation raced with another workflow; continuing." + } + fi + + # Job-level `if` cannot read the secrets context, so this tiny job exposes + # whether Apple signing is configured as an output for publish-macos to gate on. + apple-signing-gate: + runs-on: ubuntu-latest + outputs: + enabled: ${{ steps.check.outputs.enabled }} + steps: + - id: check + run: echo "enabled=${{ secrets.APPLE_CERTIFICATE != '' }}" >> "$GITHUB_OUTPUT" + + publish-macos: + needs: [create-release, apple-signing-gate] + # COMPAT(macOS-signing): skipped (not failed) while the fork has no Apple + # Developer Program certs, so tag-push release runs stay green on + # Windows/Linux. Drop the apple-signing-gate condition once Apple signing + # secrets are configured on this repo. + if: ${{ always() && needs.apple-signing-gate.outputs.enabled == 'true' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + electron_arch: arm64 + - runner: macos-15-intel + electron_arch: x64 + permissions: + contents: write + packages: read + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ env.CHECKOUT_REF }} + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install JS dependencies + run: npm ci + + - name: Set desktop package version from tag + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const version = process.env.DESKTOP_VERSION; + if (!version) throw new Error('DESKTOP_VERSION env var is missing'); + + const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.version = version; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + + - name: Build desktop release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EP_GH_IGNORE_TIME: true + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + OTTO_DESKTOP_SMOKE: "1" + run: | + set -euo pipefail + build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") + npm run build:desktop "${build_args[@]}" + + - name: Upload desktop artifacts to release + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + release_dir="${DESKTOP_PACKAGE_PATH}/release" + files=() + while IFS= read -r -d '' f; do + files+=("$f") + done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) + if (( ${#files[@]} == 0 )); then + echo "::error::No release artifacts found in $release_dir" + exit 1 + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + + - name: Upload desktop artifacts to workflow + if: env.SHOULD_PUBLISH != 'true' + uses: actions/upload-artifact@v7 + with: + name: desktop-macos-${{ matrix.electron_arch }} + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg + retention-days: 7 + + - name: Upload manifest artifact + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + uses: actions/upload-artifact@v7 + with: + name: mac-manifest-${{ matrix.electron_arch }} + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml + retention-days: 1 + + build-macos-unsigned: + needs: [create-release] + # Unsigned macOS builds are direct-download artifacts only. They + # intentionally do not feed release manifests or auto-update because + # hardened runtime and notarization require a real Apple Developer identity. + if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + electron_arch: arm64 + - runner: macos-15-intel + electron_arch: x64 + permissions: + contents: write + packages: read + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ env.CHECKOUT_REF }} + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install JS dependencies + run: npm ci + + - name: Set desktop package version from tag + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const version = process.env.DESKTOP_VERSION; + if (!version) throw new Error('DESKTOP_VERSION env var is missing'); + + const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.version = version; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + + - name: Build unsigned desktop package + shell: bash + env: + EP_GH_IGNORE_TIME: true + CSC_IDENTITY_AUTO_DISCOVERY: "false" + OTTO_DESKTOP_SMOKE: "1" + run: | + set -euo pipefail + build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") + build_args+=('-c.mac.artifactName=Otto-${version}-${arch}-unsigned.${ext}') + build_args+=("-c.mac.hardenedRuntime=false") + build_args+=("-c.mac.notarize=false") + build_args+=("-c.directories.output=release-unsigned") + npm run build:desktop "${build_args[@]}" + + - name: Upload unsigned desktop artifacts to release + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + release_dir="${DESKTOP_PACKAGE_PATH}/release-unsigned" + files=() + while IFS= read -r -d '' f; do + files+=("$f") + done < <(find "$release_dir" -maxdepth 1 -type f \( -name '*.dmg' -o -name '*.zip' \) -print0 | sort -z) + if (( ${#files[@]} == 0 )); then + echo "::error::No unsigned macOS release artifacts found in $release_dir" + exit 1 + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + + - name: Upload unsigned desktop artifacts to workflow + if: env.SHOULD_PUBLISH != 'true' + uses: actions/upload-artifact@v7 + with: + name: desktop-macos-unsigned-${{ matrix.electron_arch }} + path: | + ${{ env.DESKTOP_PACKAGE_PATH }}/release-unsigned/*.dmg + ${{ env.DESKTOP_PACKAGE_PATH }}/release-unsigned/*.zip + if-no-files-found: error + retention-days: 7 + + publish-linux: + needs: [create-release] + if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }} + permissions: + contents: write + packages: read + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ env.CHECKOUT_REF }} + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install JS dependencies + run: npm ci + + - name: Set desktop package version from tag + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const version = process.env.DESKTOP_VERSION; + if (!version) throw new Error('DESKTOP_VERSION env var is missing'); + + const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.version = version; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + + - name: Install Linux smoke display + run: sudo apt-get update && sudo apt-get install -y xvfb + + - name: Build desktop release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EP_GH_IGNORE_TIME: true + OTTO_DESKTOP_SMOKE: "1" + run: | + set -euo pipefail + build_args=(-- --publish never --linux --x64) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") + npm run build:desktop "${build_args[@]}" + + - name: Upload desktop artifacts to release + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + release_dir="${DESKTOP_PACKAGE_PATH}/release" + files=() + while IFS= read -r -d '' f; do + files+=("$f") + done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) + if (( ${#files[@]} == 0 )); then + echo "::error::No release artifacts found in $release_dir" + exit 1 + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + + - name: Upload desktop artifacts to workflow + if: env.SHOULD_PUBLISH != 'true' + uses: actions/upload-artifact@v7 + with: + name: desktop-linux + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/* + retention-days: 7 + + - name: Upload manifest artifact + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + uses: actions/upload-artifact@v7 + with: + name: linux-manifest + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-linux.yml + retention-days: 1 + + publish-windows: + needs: [create-release] + if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }} + permissions: + contents: write + packages: read + runs-on: windows-latest + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ env.CHECKOUT_REF }} + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install JS dependencies + run: npm ci + + - name: Set desktop package version from tag + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const version = process.env.DESKTOP_VERSION; + if (!version) throw new Error('DESKTOP_VERSION env var is missing'); + + const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.version = version; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + + - name: Build desktop release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EP_GH_IGNORE_TIME: true + OTTO_DESKTOP_SMOKE: "1" + run: | + set -euo pipefail + build_args=(-- --publish never --win --x64 --arm64) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") + npm run build:desktop "${build_args[@]}" + + - name: Upload desktop artifacts to release + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + release_dir="${DESKTOP_PACKAGE_PATH}/release" + files=() + while IFS= read -r -d '' f; do + files+=("$f") + done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) + if (( ${#files[@]} == 0 )); then + echo "::error::No release artifacts found in $release_dir" + exit 1 + fi + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" + + - name: Upload desktop artifacts to workflow + if: env.SHOULD_PUBLISH != 'true' + uses: actions/upload-artifact@v7 + with: + name: desktop-windows + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/* + retention-days: 7 + + - name: Upload manifest artifact + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' + uses: actions/upload-artifact@v7 + with: + name: windows-manifest + path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}.yml + retention-days: 1 + + finalize-rollout: + needs: [publish-macos, publish-linux, publish-windows] + # COMPAT(macOS-signing): macOS builds are skipped on this fork (no Apple + # Developer Program certs — see apple-signing-gate), so unlike upstream this + # gate tolerates publish-macos being skipped or failed — otherwise + # Windows/Linux auto-update manifests would never publish. The assemble step + # below already skips the mac manifest unless publish-macos succeeded. + # Restore the macOS success requirement once Apple signing is configured. + if: ${{ always() && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-windows.result == 'success' || needs.publish-macos.result == 'success') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} + permissions: + contents: write + runs-on: ubuntu-latest + concurrency: + group: desktop-rollout-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + package.json + package-lock.json + scripts + ref: ${{ env.CHECKOUT_REF }} + + - name: Resolve release tag + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install JS dependencies + run: npm ci + + - name: Download mac manifest artifacts + if: env.IS_SMOKE_TAG != 'true' && needs.publish-macos.result == 'success' + uses: actions/download-artifact@v8 + with: + pattern: mac-manifest-* + path: release-manifests + + - name: Download Linux manifest artifact + if: env.IS_SMOKE_TAG != 'true' && needs.publish-linux.result == 'success' + uses: actions/download-artifact@v8 + with: + name: linux-manifest + path: release-manifests/linux-manifest + + - name: Download Windows manifest artifact + if: env.IS_SMOKE_TAG != 'true' && needs.publish-windows.result == 'success' + uses: actions/download-artifact@v8 + with: + name: windows-manifest + path: release-manifests/windows-manifest + + - name: Assemble and upload stamped manifests + if: env.IS_SMOKE_TAG != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + cd release-manifests + manifests_dir="$PWD/final" + mkdir -p "$manifests_dir" + + if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then + manifest_name="${RELEASE_CHANNEL}-mac.yml" + node ../scripts/merge-mac-manifest.mjs \ + "mac-manifest-arm64/${manifest_name}" \ + "mac-manifest-x64/${manifest_name}" \ + "$manifests_dir/${manifest_name}" + fi + + if [[ "${{ needs.publish-linux.result }}" == "success" ]]; then + cp "linux-manifest/${RELEASE_CHANNEL}-linux.yml" "$manifests_dir/" + fi + + if [[ "${{ needs.publish-windows.result }}" == "success" ]]; then + cp "windows-manifest/${RELEASE_CHANNEL}.yml" "$manifests_dir/" + fi + + shopt -s nullglob + files=( "$manifests_dir"/*.yml ) + if (( ${#files[@]} == 0 )); then + echo "::error::No manifest artifacts were available to publish" + exit 1 + fi + timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" + node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" + ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e ' + const yaml = require("js-yaml"); + const fs = require("fs"); + const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED); + const expectedDate = process.env.RELEASE_DATE_EXPECTED; + if (!Number.isFinite(expectedHours) || expectedHours < 0) { + throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`); + } + for (const f of process.argv.slice(1)) { + const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; + if (m.rolloutHours !== expectedHours) { + throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`); + } + if (m.releaseDate !== expectedDate) { + throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`); + } + if (typeof m.version !== "string" || m.version.length === 0) { + throw new Error(`${f}: missing or invalid version`); + } + } + ' "${files[@]}" + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}" diff --git a/.github/workflows/desktop-rollout.yml b/.github/workflows/desktop-rollout.yml new file mode 100644 index 000000000..d725f8e7f --- /dev/null +++ b/.github/workflows/desktop-rollout.yml @@ -0,0 +1,148 @@ +name: Desktop Rollout + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to re-stamp (e.g. v0.1.42)." + required: true + type: string + rollout_hours: + description: "Total rollout duration since the original release date, in hours. Use 0 to admit everyone immediately." + required: true + type: string + +concurrency: + group: desktop-rollout-${{ inputs.tag }} + cancel-in-progress: false + +env: + SOURCE_TAG: ${{ inputs.tag }} + +jobs: + stamp: + permissions: + contents: write + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + package.json + package-lock.json + scripts + + - name: Resolve release metadata + shell: bash + run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV" + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install JS dependencies + run: npm ci + + - name: Download release manifests + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + mkdir release-manifests + cd release-manifests + gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml" + shopt -s nullglob + files=( ./*.yml ) + if (( ${#files[@]} == 0 )); then + echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG" + exit 1 + fi + echo "Downloaded ${#files[@]} manifest(s):" + printf ' %s\n' "${files[@]}" + + - name: Capture before state + id: before + shell: bash + run: | + set -euo pipefail + cd release-manifests + summary=$(node -e ' + const yaml = require("js-yaml"); + const fs = require("fs"); + for (const f of process.argv.slice(1)) { + const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; + console.log(` ${f}: rolloutHours=${m.rolloutHours ?? ""} releaseDate=${m.releaseDate ?? ""}`); + } + ' ./*.yml) + echo "$summary" + { + echo "summary<> "$GITHUB_OUTPUT" + + - name: Re-stamp rolloutHours + env: + NEW_HOURS: ${{ inputs.rollout_hours }} + shell: bash + run: | + set -euo pipefail + cd release-manifests + node ../scripts/stamp-rollout.mjs --rollout-hours "$NEW_HOURS" ./*.yml + + - name: Validate rewritten manifests + env: + EXPECTED: ${{ inputs.rollout_hours }} + shell: bash + run: | + set -euo pipefail + cd release-manifests + node -e ' + const yaml = require("js-yaml"); + const fs = require("fs"); + const expected = Number(process.env.EXPECTED); + if (!Number.isFinite(expected) || expected < 0) { + throw new Error(`EXPECTED must be a non-negative number, got ${process.env.EXPECTED}`); + } + for (const f of process.argv.slice(1)) { + const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; + if (m.rolloutHours !== expected) { + throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expected}`); + } + if (typeof m.version !== "string" || m.version.length === 0) { + throw new Error(`${f}: missing or invalid version`); + } + } + ' ./*.yml + + - name: Upload to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + cd release-manifests + gh release upload "$RELEASE_TAG" ./*.yml --clobber --repo "${{ github.repository }}" + + - name: Write summary + env: + BEFORE: ${{ steps.before.outputs.summary }} + shell: bash + run: | + { + echo "## Rollout updated" + echo "" + echo "**Tag:** \`$RELEASE_TAG\`" + echo "**Channel:** \`$RELEASE_CHANNEL\`" + echo "**New rolloutHours:** \`${{ inputs.rollout_hours }}\`" + echo "" + echo "### Before" + echo '```' + echo "$BEFORE" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..553589de0 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,189 @@ +name: Docker + +on: + pull_request: + branches: [main] + paths: + - "docker/**" + - ".dockerignore" + - ".github/workflows/docker.yml" + - "package.json" + - "package-lock.json" + - "patches/**" + - "scripts/**" + - "tsconfig.json" + - "tsconfig.base.json" + - "packages/app/**" + - "packages/cli/**" + - "packages/client/**" + - "packages/expo-two-way-audio/**" + - "packages/highlight/**" + - "packages/protocol/**" + - "packages/relay/**" + - "packages/server/**" + push: + branches: [main] + tags: + - "v*" + workflow_dispatch: + inputs: + otto_version: + description: "Expected source version to build. Required when publish is true." + required: false + default: "" + publish: + description: "Publish the image to GHCR. Manual publishes require otto_version." + required: false + default: "false" + type: choice + options: + - "false" + - "true" + publish_latest: + description: "Also publish ghcr.io/draek2077/otto:latest. Ignored for prereleases." + required: false + default: "false" + type: choice + options: + - "false" + - "true" + +concurrency: + group: docker-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + REGISTRY: ghcr.io + PLATFORMS: linux/amd64,linux/arm64 + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + image: ${{ steps.values.outputs.image }} + install_version: ${{ steps.values.outputs.install_version }} + publish: ${{ steps.values.outputs.publish }} + check_tag: ${{ steps.values.outputs.check_tag }} + publish_tags: ${{ steps.values.outputs.publish_tags }} + steps: + - uses: actions/checkout@v7 + + - id: values + env: + INPUT_OTTO_VERSION: ${{ inputs.otto_version }} + INPUT_PUBLISH: ${{ inputs.publish }} + INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }} + REPO_OWNER: ${{ github.repository_owner }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + + owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')" + image="ghcr.io/${owner}/otto" + package_version="$(node -p "require('./package.json').version")" + install_version="${INPUT_OTTO_VERSION:-${package_version}}" + publish=false + publish_latest=false + prerelease=false + + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + install_version="${REF_NAME#v}" + publish=true + if [[ "${REF_NAME}" == *-* ]]; then + prerelease=true + else + publish_latest=true + fi + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then + if [[ -z "${INPUT_OTTO_VERSION}" || "${INPUT_OTTO_VERSION}" == "latest" ]]; then + echo "::error::otto_version is required for manual Docker publishes." + exit 1 + fi + publish=true + fi + + if [[ "${install_version}" == *-* ]]; then + prerelease=true + fi + + if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then + publish_latest=true + fi + fi + + check_tag="${image}:check-${GITHUB_SHA::12}" + publish_tags="${image}:${install_version}" + if [[ "${publish_latest}" == "true" ]]; then + publish_tags="${publish_tags}"$'\n'"${image}:latest" + fi + + { + echo "image=${image}" + echo "install_version=${install_version}" + echo "publish=${publish}" + echo "check_tag=${check_tag}" + echo "publish_tags<> "$GITHUB_OUTPUT" + + echo "Resolved image=${image} install_version=${install_version} publish=${publish}" + + build: + needs: setup + if: needs.setup.outputs.publish != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + + - uses: docker/build-push-action@v7 + with: + context: . + file: docker/base/Dockerfile + platforms: ${{ env.PLATFORMS }} + build-args: | + OTTO_VERSION=${{ needs.setup.outputs.install_version }} + tags: ${{ needs.setup.outputs.check_tag }} + push: false + provenance: false + cache-from: type=gha,scope=otto + cache-to: type=gha,scope=otto,mode=max + + publish: + needs: setup + if: needs.setup.outputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4.5.2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v7 + with: + context: . + file: docker/base/Dockerfile + platforms: ${{ env.PLATFORMS }} + build-args: | + OTTO_VERSION=${{ needs.setup.outputs.install_version }} + tags: ${{ needs.setup.outputs.publish_tags }} + push: true + provenance: false + cache-from: type=gha,scope=otto + cache-to: type=gha,scope=otto,mode=max diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 000000000..54131c3bf --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -0,0 +1,155 @@ +name: iOS Release + +# Builds an iOS app via EAS and submits it to App Store Connect (lands in +# TestFlight internal testing) on a release tag. Mirrors +# android-play-release.yml's shape: build once via EAS, submit separately so a +# failed submit can be retried without rebuilding. +# +# COMPAT(ios-signing): skipped (not failed) while this fork has no Apple +# Developer Program account, so tag-push release runs stay green without an +# App Store Connect API key configured. See ios-signing-gate below and +# docs/fork-release-guide.md for the one-time Apple/ASC setup this needs. +# +# Requires these repo secrets (all gate the workflow via ios-signing-gate): +# - EXPO_TOKEN (already set; authenticates the EAS build) +# - APPLE_TEAM_ID (shared with desktop macOS signing — same team) +# - ASC_API_KEY_P8 (contents of the downloaded .p8 API key file) +# - ASC_API_KEY_ID (Key ID from App Store Connect → Users and +# Access → Integrations → Keys) +# - ASC_API_KEY_ISSUER_ID (Issuer ID from the same Keys page) +# +# Also requires packages/app/eas.json's submit.production.ios.ascAppId to be +# set to the real App Store Connect numeric app ID (not a secret — it's +# public in the ASC URL — but there's no app record yet, so it's a +# placeholder until one exists). +# +# First-run gotcha: EAS can usually generate the iOS distribution certificate +# and provisioning profile automatically from the ASC API key alone (the key +# needs the Admin role in App Store Connect). If a build fails asking to +# re-run in interactive mode for credential setup, run +# `cd packages/app && npx eas credentials` locally once (with the Apple +# account) to generate/store them on Expo's servers, then CI builds pick them +# up automatically from then on. + +on: + push: + tags: + - "v*" + - "ios-v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to check out (e.g. v0.5.1 or ios-v0.5.1)" + required: true + type: string + build_id: + description: "Existing EAS build id to submit as-is (skips the build). Leave empty to build from the tag." + required: false + type: string + default: "" + +concurrency: + group: ios-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + # Job-level `if` cannot read the secrets context, so this tiny job exposes + # whether ASC signing is configured as an output for submit-ios to gate on. + ios-signing-gate: + runs-on: ubuntu-latest + outputs: + enabled: ${{ steps.check.outputs.enabled }} + steps: + - id: check + run: echo "enabled=${{ secrets.ASC_API_KEY_P8 != '' && secrets.ASC_API_KEY_ID != '' && secrets.ASC_API_KEY_ISSUER_ID != '' && secrets.APPLE_TEAM_ID != '' }}" >> "$GITHUB_OUTPUT" + + submit-ios: + needs: [ios-signing-gate] + if: ${{ needs.ios-signing-gate.outputs.enabled == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Install JS dependencies + run: npm ci + + - name: Setup Expo and EAS + uses: expo/expo-github-action@v9 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Write App Store Connect API key + env: + ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }} + shell: bash + run: | + set -euo pipefail + printf '%s' "$ASC_API_KEY_P8" > "$RUNNER_TEMP/asc-api-key.p8" + + - name: Build iOS app on EAS + id: eas_build + # Skip the build when a caller supplies an existing build id to submit + # as-is (retry-a-failed-submit path). Empty for tag pushes, so they build. + if: ${{ github.event.inputs.build_id == '' }} + shell: bash + env: + EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc-api-key.p8 + EXPO_ASC_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + EXPO_ASC_ISSUER_ID: ${{ secrets.ASC_API_KEY_ISSUER_ID }} + EXPO_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + cd packages/app + + build_json="$(npx eas build --platform ios --profile production --non-interactive --wait --json)" + echo "$build_json" > "$RUNNER_TEMP/eas-build.json" + + build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")" + if [ -z "$build_id" ]; then + echo "Failed to determine EAS build ID." >&2 + cat "$RUNNER_TEMP/eas-build.json" + exit 1 + fi + + echo "build_id=$build_id" >> "$GITHUB_OUTPUT" + + - name: Submit build to App Store Connect (TestFlight) + shell: bash + env: + INPUT_BUILD_ID: ${{ github.event.inputs.build_id }} + BUILT_BUILD_ID: ${{ steps.eas_build.outputs.build_id }} + EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc-api-key.p8 + EXPO_ASC_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + EXPO_ASC_ISSUER_ID: ${{ secrets.ASC_API_KEY_ISSUER_ID }} + EXPO_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + cd packages/app + # Prefer a caller-supplied build id (submit-only retry); otherwise use + # the id from the build step above. + submit_id="${INPUT_BUILD_ID:-}" + if [ -z "$submit_id" ]; then submit_id="${BUILT_BUILD_ID:-}"; fi + if [ -z "$submit_id" ]; then + echo "No EAS build id to submit (build step skipped and no build_id input)." >&2 + exit 1 + fi + echo "Submitting EAS build $submit_id to App Store Connect" + npx eas submit --platform ios --profile production --id "$submit_id" --non-interactive + + - name: Clean up API key + if: always() + shell: bash + run: rm -f "$RUNNER_TEMP/asc-api-key.p8" diff --git a/.github/workflows/nix-update-hash.yml b/.github/workflows/nix-update-hash.yml new file mode 100644 index 000000000..dab32b71a --- /dev/null +++ b/.github/workflows/nix-update-hash.yml @@ -0,0 +1,56 @@ +name: Nix Update Hash + +on: + push: + branches: [main] + paths: + - "nix/**" + - "flake.nix" + - "flake.lock" + - "package.json" + - "package-lock.json" + - "packages/highlight/**" + - "packages/server/**" + - "packages/relay/**" + - "packages/cli/**" + - "scripts/update-nix.sh" + - "scripts/fix-lockfile.mjs" + - ".github/workflows/nix-update-hash.yml" + +permissions: + contents: write + +jobs: + update-hash: + runs-on: ubuntu-latest + steps: + # This fork uses the default GITHUB_TOKEN instead of upstream's bot GitHub + # App (OTTO_BOT_APP_ID / OTTO_BOT_APP_PRIVATE_KEY secrets don't exist here). + # The [skip ci] in the commit message keeps the push from re-triggering CI. + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Update lockfile + Nix hash if stale + run: ./scripts/update-nix.sh + + - name: Build Nix package + run: nix build .#default -o result + + - name: Commit hash/lockfile updates + run: | + git diff --quiet package-lock.json nix/npm-deps.hash && exit 0 + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package-lock.json nix/npm-deps.hash + git commit -m "fix: update lockfile signatures and Nix hash [skip ci]" + git push diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..a593f1e57 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,101 @@ +name: Nix + +on: + pull_request: + branches: [main] + paths: + - "nix/**" + - "flake.nix" + - "flake.lock" + - "package.json" + - "package-lock.json" + - "packages/highlight/**" + - "packages/protocol/**" + - "packages/client/**" + - "packages/server/**" + - "packages/relay/**" + - "packages/cli/**" + - "scripts/update-nix.sh" + - "scripts/fix-lockfile.mjs" + - ".github/workflows/nix.yml" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Update lockfile + Nix hash if stale + run: ./scripts/update-nix.sh + + - name: Build Nix package + run: nix build .#default -o result + + - name: Smoke Nix daemon + run: | + set -euo pipefail + + export OTTO_HOME + OTTO_HOME="$(mktemp -d)" + export OTTO_LISTEN=127.0.0.1:6868 + + WRAPPER_LOG="$OTTO_HOME/otto-server-wrapper.log" + + cleanup() { + if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then + kill "$DAEMON_PID" + wait "$DAEMON_PID" || true + fi + rm -rf "$OTTO_HOME" + } + trap cleanup EXIT + + ./result/bin/otto-server --no-relay >"$WRAPPER_LOG" 2>&1 & + DAEMON_PID=$! + + deadline=$((SECONDS + 30)) + while (( SECONDS < deadline )); do + if STATUS_JSON="$(./result/bin/otto daemon status --json)" \ + && jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then + echo "$STATUS_JSON" + exit 0 + fi + + if ! kill -0 "$DAEMON_PID" 2>/dev/null; then + echo "Nix daemon exited before becoming reachable." + break + fi + + sleep 1 + done + + echo "::group::daemon.log" + cat "$OTTO_HOME/daemon.log" 2>/dev/null || echo "" + echo "::endgroup::" + + echo "::group::otto-server stdout/stderr" + cat "$WRAPPER_LOG" 2>/dev/null || echo "" + echo "::endgroup::" + + echo "::group::otto daemon status" + ./result/bin/otto daemon status || true + echo "::endgroup::" + + exit 1 + + - name: Build Nix desktop package + run: nix build .#desktop -o result-desktop diff --git a/.github/workflows/release-notes-sync.yml b/.github/workflows/release-notes-sync.yml new file mode 100644 index 000000000..78abb9ab2 --- /dev/null +++ b/.github/workflows/release-notes-sync.yml @@ -0,0 +1,74 @@ +name: Release Notes Sync + +on: + push: + tags: + - "v*" + branches: + - main + paths: + - "CHANGELOG.md" + workflow_dispatch: + inputs: + tag: + description: "Release tag to sync (e.g. v0.1.14). Leave empty to use top changelog entry." + required: false + type: string + create_if_missing: + description: "Create release if missing (normally only needed for tag events)." + required: false + default: false + type: boolean + +# Shared with desktop-release.yml and android-apk-release.yml so the three +# workflows that can create this release queue instead of racing. See the note +# on desktop-release.yml's create-release job for why a race here is invisible +# at creation time and only surfaces later as a 422 on update. This workflow +# reaches the create path only on a tag push (--create-if-missing below), but it +# shares the group anyway so its update also lands after any pending create. +# Must stay byte-identical in all three. +concurrency: + group: otto-release-ensure-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + sync-release-notes: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Sync release body from changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }} + INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }} + shell: bash + run: | + set -euo pipefail + + args=(--repo "$REPO") + + if [ -n "${INPUT_TAG:-}" ]; then + args+=(--tag "$INPUT_TAG") + fi + + create_if_missing="false" + if [[ "$EVENT_NAME" = "push" && "$REF" == refs/tags/v* ]]; then + create_if_missing="true" + elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then + create_if_missing="true" + fi + + if [ "$create_if_missing" = "true" ]; then + args+=(--create-if-missing) + fi + + node scripts/sync-release-notes-from-changelog.mjs "${args[@]}" diff --git a/.gitignore b/.gitignore index 669f6bd66..5c298a925 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,132 @@ -node_modules -dist -.vite -*.local -.next -*.vsix - -# Auto-generated -extension/README.md -scripts/.dev-relay.js -scripts/.dev-relay.js.map -app/dist/ -next-env.d.ts -*.tsbuildinfo +# Dependencies +node_modules/ + +# Build outputs +build/ +dist/ +.next/ +out/ +result + +# ...but packages/desktop/build/ is electron-builder SOURCE config (mac +# entitlements, linux postinst/postrm scripts, the AppArmor profile), not build +# output. Keep it tracked so new files there aren't silently ignored. +!packages/desktop/build/ -# Local IDE config -extension/.vscode/ -.vscode/* -!.vscode/launch.json -!.vscode/tasks.json -!.vscode/settings.json +# .NET solution sidecar build output (`npm run build:dotnet-probe` regenerates it; the +# shipped payload is a build artifact, never a committed one) +packages/dotnet-probe/bin/ +packages/dotnet-probe/obj/ -# Claude Code -.claude/ +# graphify — knowledge graph outputs are personal/derived; .graphifyignore stays tracked +graphify-out/ -# Subdirectory lock files (project uses pnpm workspace) -extension/package-lock.json +# understand-anything — all tool output (intermediate/, tmp/, knowledge-graph.json); .understandignore lives inside and is ignored with it +.ua/ +# Environment variables +.env +.env.test +.env.local +.env.test.local +.env*.local +.dev.vars +**/.dev.vars + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS files .DS_Store +*.swp +*.swo +*~ + +# TypeScript +*.tsbuildinfo +tsconfig.tsbuildinfo + +# IDEs +.vscode/ +.idea/ +*.sublime-* + +# Testing +coverage/ + +# Otto Brain benchmark run artifacts (results + transcripts), written to +# packages/brain/results at runtime. Machine-local, not source. +packages/brain/results/ + +# Playwright +test-results/ +**/test-results/ +playwright-report/ +**/playwright-report/ +# Per-tier report dirs (E2E_HTML_REPORT_DIR / E2E_REPORT_DIR) so a T2/T3 run +# never overwrites the T1 report mid-write. +playwright-report-*/ +**/playwright-report-*/ +# QA run artifacts (index/evidence/money-shot digest/failure report) — regenerated every run +e2e-report/ +**/e2e-report/ +e2e-report-*/ +**/e2e-report-*/ + +# Vercel +.vercel/ + +# Expo +.expo/ + +# Misc +*.pem +.vercel + +# Local Claude configuration +CLAUDE.local.md + +# Task CLI +.tasks/ + +.debug.conversations/ +.debug/ +.dev/ +.otto/ +.wrangler/ +**/.wrangler/ +**/.tanstack/ + +# Local agent/tooling artifacts (do not commit) +PLAN.md +.valknut/ +valknut-report.html/ +valknut-report.json/ +**/.playwright-mcp/ +**/.otto-provider-history/ +.claude/settings.local.json +**/.claude/settings.local.json +.claude/scheduled_tasks.lock +.claude/worktrees/ +.plans/ +.cache/ +.npm-cache/ +packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav +packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt +packages/protocol/src/generated/validation/*.aot.ts + +/artifacts +packages/desktop/.cache/ +packages/desktop/src-tauri/resources/managed-runtime/ +app.json + +# Site demo captures (regenerated by `npm run demo` + `npm run demo:assets`) +packages/app/demo/.out/ +packages/website/public/demos/ + +# Local archive of removed docs/projects (kept on disk, out of git) +archive/ diff --git a/.graphifyignore b/.graphifyignore new file mode 100644 index 000000000..0c0c212f4 --- /dev/null +++ b/.graphifyignore @@ -0,0 +1,49 @@ +# Build / release artifacts (belt-and-suspenders on top of .gitignore) +dist/ +build/ +out/ +release/ +packages/*/dist/ +packages/*/build/ +packages/*/release/ +packages/*/.dev/ +packages/desktop/release/ +packages/desktop/dist/ +packages/app/.expo/ +packages/app/web-build/ +packages/app/android/build/ +packages/app/ios/build/ +packages/website/.next/ +packages/website/out/ + +# Bundled JS (Expo static, minified webpack output) +**/expo/static/** +**/unpacked/** +**/*.min.js +**/*.bundle.js +**/*.chunk.js + +# Generated / cache +.dev/ +.turbo/ +.cache/ +coverage/ +.playwright/ +playwright-report/ +test-results/ +**/__snapshots__/ +**/*.tsbuildinfo + +# Vendored / checked-in binaries, model catalogs +**/*.wasm +**/*.onnx +**/*.tflite + +# Sync artifacts (i18n mirror, generated schemas) +packages/protocol/src/generated/** +**/*.d.ts.map +**/*.js.map + +# Docs' rendered output only (keep source .md) +docs/_build/ +public-docs/dist/ diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 000000000..554d085a5 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,9 @@ +[env] +ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0" +_.path = [ + "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools", + "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator", +] + +[tools] +java = "17" diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..02f8ea08c --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,24 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "useTabs": false, + "tabWidth": 2, + "printWidth": 100, + "singleQuote": false, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "all", + "semi": true, + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "ignorePatterns": [ + "vendor/**", + "**/vendor/**", + "test-documents/**", + "*.lock", + "**/*.gen.ts", + "**/*.gen.tsx", + "AGENTS.md", + "packages/server/AGENTS.md" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..9226357a0 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,314 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "options": { + "typeAware": false + }, + "ignorePatterns": [ + ".dev/**", + "packages/app/demo/staging/templates/**", + "vendor/**", + "test-documents/**" + ], + "plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error" + }, + "rules": { + "react/react-in-jsx-scope": "off", + + "no-await-in-loop": "off", + + "no-unused-expressions": "error", + "no-useless-catch": "error", + "preserve-caught-error": "error", + "require-await": "off", + "no-async-promise-executor": "error", + "no-useless-escape": "error", + "no-empty-pattern": "error", + "no-self-assign": "error", + "no-shadow": "error", + "unicorn/consistent-function-scoping": "off", + "unicorn/no-array-sort": "off", + + "unicorn/no-useless-spread": "error", + "unicorn/no-useless-fallback-in-spread": "error", + "unicorn/no-new-array": "error", + "unicorn/no-empty-file": "error", + + "promise/always-return": "error", + "promise/no-multiple-resolved": "error", + + "react/no-array-index-key": "error", + "react/jsx-no-useless-fragment": "error", + "react/jsx-no-constructed-context-values": "error", + "react/no-unescaped-entities": "error", + "react/button-has-type": "error", + "react/jsx-max-depth": ["error", { "max": 6 }], + + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + + "react-perf/jsx-no-new-array-as-prop": "error", + "react-perf/jsx-no-new-function-as-prop": "error", + "react-perf/jsx-no-new-object-as-prop": "error", + "react-perf/jsx-no-jsx-as-prop": "error", + + "oxc/no-map-spread": "error", + "oxc/no-async-endpoint-handlers": "error", + "oxc/only-used-in-recursion": "error", + + "typescript/no-explicit-any": "error", + "typescript/prefer-as-const": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-type-assertion": "error", + "typescript/consistent-type-definitions": ["error", "interface"], + + "import/no-unassigned-import": [ + "error", + { + "allow": [ + "**/*.css", + "**/expo-router/entry", + "**/event-target-polyfill", + "**/dotenv/config", + "**/react-native", + "**/@/styles/unistyles", + "**/src/styles/unistyles", + "**/@/test/window-local-storage" + ] + } + ], + + "no-nested-ternary": "error", + "no-unneeded-ternary": "error", + + "complexity": ["error", { "max": 20 }], + "max-depth": ["error", { "max": 4 }], + "max-nested-callbacks": ["error", { "max": 3 }] + }, + "overrides": [ + { + "files": ["packages/app/src/**/*.{ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@tanstack/react-query", + "importNames": ["useQuery", "useInfiniteQuery", "useQueries"], + "message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list." + }, + { + "name": "react-native-unistyles", + "importNames": ["useUnistyles"], + "message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list." + } + ] + } + ] + } + }, + { + "files": [ + "packages/app/src/data/**/*.{ts,tsx}", + "packages/app/src/**/*.test.{ts,tsx}", + "packages/app/src/**/*.spec.{ts,tsx}" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-native-unistyles", + "importNames": ["useUnistyles"], + "message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list." + } + ] + } + ] + } + }, + // Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban. + { + "files": [ + "packages/app/src/artifacts/use-artifact-content.ts", + "packages/app/src/artifacts/use-artifacts.ts", + "packages/app/src/assistant-file-links/use-file-link.ts", + "packages/app/src/components/message.tsx", + "packages/app/src/components/sidebar/sidebar-active-workspace-tools.tsx", + "packages/app/src/components/worktree-setup-callout-source.tsx", + "packages/app/src/desktop/hooks/use-daemon-status.ts", + "packages/app/src/desktop/hooks/use-install-status.ts", + "packages/app/src/desktop/settings/desktop-settings.ts", + "packages/app/src/git/pull-request-panel/use-data.ts", + "packages/app/src/git/use-github-search-query.ts", + "packages/app/src/git/use-pr-status-query.ts", + "packages/app/src/git/use-status-query.ts", + "packages/app/src/hooks/use-archive-agent.ts", + "packages/app/src/hooks/use-agent-autocomplete.ts", + "packages/app/src/hooks/use-agent-commands-query.ts", + "packages/app/src/hooks/use-agent-history.ts", + "packages/app/src/hooks/use-branch-switcher.ts", + "packages/app/src/hooks/use-changes-preferences/index.ts", + "packages/app/src/hooks/use-agent-context-usage.ts", + "packages/app/src/hooks/use-draft-agent-features.ts", + "packages/app/src/hooks/use-form-preferences.ts", + "packages/app/src/hooks/use-is-local-daemon.ts", + "packages/app/src/hooks/use-keyboard-shortcut-overrides.ts", + "packages/app/src/hooks/use-preferred-editor.ts", + "packages/app/src/hooks/use-project-icon-query.ts", + "packages/app/src/hooks/use-settings/index.ts", + "packages/app/src/panels/terminal-panel.tsx", + "packages/app/src/projects/project-icons.ts", + "packages/app/src/provider-usage/use-provider-usage.ts", + "packages/app/src/screens/project-settings-screen.tsx", + "packages/app/src/screens/workspace/use-workspace-checkout-status.ts", + "packages/app/src/workspace/desktop-open-targets.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-native-unistyles", + "importNames": ["useUnistyles"], + "message": "useUnistyles is banned by docs/unistyles.md. Grandfathered files may only leave the override burn-down list." + } + ] + } + ] + } + }, + // useUnistyles burn-down: 74 files total; the 68 files below still enforce the raw-query ban. + { + "files": [ + "packages/app/src/app/_layout.tsx", + "packages/app/src/app/pair-scan.tsx", + "packages/app/src/components/adaptive-modal-sheet.tsx", + "packages/app/src/components/chat-width-bounds.tsx", + "packages/app/src/panels/artifact-panel.tsx", + "packages/app/src/components/add-host-method-modal.tsx", + "packages/app/src/components/add-host-modal.tsx", + "packages/app/src/components/agent-list.tsx", + "packages/app/src/components/agent-status-dot.tsx", + "packages/app/src/components/attachment-lightbox.tsx", + "packages/app/src/components/browser-pane.electron.tsx", + "packages/app/src/components/browser-pane.tsx", + "packages/app/src/components/browser-pane.web.tsx", + "packages/app/src/components/command-center.tsx", + "packages/app/src/components/context-window-meter.tsx", + "packages/app/src/components/dictation-controls.tsx", + "packages/app/src/components/download-toast.tsx", + "packages/app/src/components/draggable-list.native.tsx", + "packages/app/src/components/explorer-sidebar.tsx", + "packages/app/src/components/headers/back-header.tsx", + "packages/app/src/components/headers/menu-header.tsx", + "packages/app/src/components/headers/screen-header.tsx", + "packages/app/src/components/host-status-dot.tsx", + "packages/app/src/components/hosts/host-picker.tsx", + "packages/app/src/components/icons/otto-logo.tsx", + "packages/app/src/components/left-sidebar.tsx", + "packages/app/src/components/pair-link-modal.tsx", + "packages/app/src/components/plan-card.tsx", + "packages/app/src/components/provider-diagnostic-sheet.tsx", + "packages/app/src/components/question-form-card.tsx", + "packages/app/src/components/quitting-overlay.tsx", + "packages/app/src/components/realtime-voice-overlay.tsx", + "packages/app/src/components/resize-handle.tsx", + "packages/app/src/components/rewind/rewind-menu.tsx", + "packages/app/src/components/settings-textarea.tsx", + "packages/app/src/components/sidebar-callout.tsx", + "packages/app/src/components/split-container.tsx", + "packages/app/src/components/split-drop-zone.tsx", + "packages/app/src/components/terminal-pane.tsx", + "packages/app/src/components/toast-host.tsx", + "packages/app/src/components/tool-call-sheet.tsx", + "packages/app/src/components/ui/alert.tsx", + "packages/app/src/components/ui/autocomplete.tsx", + "packages/app/src/components/ui/combobox.tsx", + "packages/app/src/components/ui/context-menu.tsx", + "packages/app/src/components/ui/dropdown-menu.tsx", + "packages/app/src/components/ui/external-link.tsx", + "packages/app/src/components/volume-meter.tsx", + "packages/app/src/components/web-desktop-scrollbar.tsx", + "packages/app/src/components/welcome-screen.tsx", + "packages/app/src/composer/agent-controls/index.tsx", + "packages/app/src/composer/agent-controls/mode-control.tsx", + "packages/app/src/constants/layout.ts", + "packages/app/src/desktop/components/desktop-permission-row.tsx", + "packages/app/src/desktop/components/desktop-permissions-section.tsx", + "packages/app/src/desktop/components/desktop-updates-section.tsx", + "packages/app/src/desktop/components/integrations-section.tsx", + "packages/app/src/desktop/updates/update-callout-source.tsx", + "packages/app/src/git/actions-split-button.tsx", + "packages/app/src/hooks/use-web-scrollbar-style.web.ts", + "packages/app/src/hosts/host-chooser.tsx", + "packages/app/src/screens/open-project-screen.tsx", + "packages/app/src/screens/projects-screen.tsx", + "packages/app/src/screens/sessions-screen.tsx", + "packages/app/src/screens/settings-screen.tsx", + "packages/app/src/screens/settings/host-page.tsx", + "packages/app/src/screens/settings/providers-section.tsx", + "packages/app/src/screens/settings/settings-group.tsx", + "packages/app/src/screens/startup-splash-screen.tsx", + "packages/app/src/screens/workspace/workspace-route-state-views.tsx" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@tanstack/react-query", + "importNames": ["useQuery", "useInfiniteQuery", "useQueries"], + "message": "App reads must go through useReplicaQuery/useFetchQuery from @/data/query. Grandfathered files may only leave the override burn-down list." + } + ] + } + ] + } + }, + // Both burn-downs: 6 overlapping files. They may only shrink out of this exemption. + { + "files": [ + "packages/app/src/components/file-explorer-pane.tsx", + "packages/app/src/components/file-pane.tsx", + "packages/app/src/components/import-session-sheet.tsx", + "packages/app/src/components/project-picker-modal.tsx", + "packages/app/src/desktop/components/pair-device-section.tsx", + "packages/app/src/screens/new-workspace-screen.tsx" + ], + "rules": { + "no-restricted-imports": "off" + } + }, + { + "files": ["**/e2e/fixtures.ts"], + "rules": { + "no-empty-pattern": "off" + } + }, + // Brain burn-down: rules carried over from the otto-brain port (TUI ANSI + // control-char regexes are intentional; complexity/depth are legacy). Shrink + // this list as the ported modules are cleaned up. + { + "files": ["packages/brain/src/**/*.ts"], + "rules": { + "complexity": "off", + "max-depth": "off", + "no-nested-ternary": "off", + "no-control-regex": "off", + "no-shadow": "off", + "unicorn/no-array-reverse": "off", + "unicorn/prefer-string-starts-ends-with": "off", + "oxc/no-map-spread": "off", + "promise/no-multiple-resolved": "off" + } + } + ] +} diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..191fb5e39 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,4 @@ +rust 1.85.1 +nodejs 24.18.1 +java 21 +android-sdk latest diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..de62391e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,690 @@ +# Changelog + +## 0.7.4 - 2026-07-30 + +### Added + +- Move a chat to another workspace from the workspace tab menu + +### Changed + +- The Changes diff in a worktree compares against the branch the worktree was created from +- The diff base you pick for a checkout is remembered the next time you open it +- The bundled agent catalog picks up current versions of CodeBuddy, DeepAgents, Dimcode, Dirac, Factory Droid, fast-agent and Qoder + +### Fixed + +- Escape no longer clears what you typed in the composer +- Voice cues, Visualizer, Explorer and Play move into the "..." menu on a narrow window instead of disappearing + +## 0.7.3 - 2026-07-29 + +### Added + +- Code Intelligence settings lists every language server this machine can run, whether or not a workspace is open + +### Changed + +- The bundled agent catalog picks up current versions of Gemini CLI, Qwen Code, Cline, Droid and others +- C# language support and the solution sidecar share a single cap on how many .NET processes Otto will start +- MSBuild workers started for a C# project exit with the work that started them instead of lingering for fifteen minutes + +### Fixed + +- Otto no longer gets steadily slower as you add workspaces, because background git work now follows the workspace you are looking at +- Switching branches in a terminal reaches the Changes sidebar instead of going unnoticed +- A long chat keeps far fewer messages rendered once you have scrolled away from them +- A screenshot in a message no longer shrinks on every layout pass until it disappears +- The Files tab no longer flashes an error when it opens before the folder listing arrives +- A slow Bash or PowerShell command no longer appears as a sub-agent that never finishes +- An interrupted background shell command on Windows no longer stays listed as running +- Detaching a sub-agent clears it from the parent's sub-agents track right away instead of only after a reload +- Releases include an Android APK again, after several shipped without one + +## 0.7.2 - 2026-07-27 + +### Added + +- Background shell tasks that fail collect in their own Failed group, in red, with their own clear-all +- Auto-clear for failed background tasks is its own setting, separate from completed ones +- Opus 4.5, Sonnet 4.5 and Sonnet 4.5 1M are selectable in the model picker + +### Changed + +- Stopping a run keeps the messages you had queued behind it instead of discarding them +- Opus 4.7, 4.8 and 5 report their real 1M context window, so the duplicate "1M" entries for them are gone +- Fast mode is offered only on the Opus releases that still support it +- The auto-clear settings for sub-agents and background tasks moved from Appearance to General +- The token count on a running turn keeps a decimal so it visibly moves instead of jumping a thousand at a time + +### Fixed + +- A failed background task no longer sits in the active list looking finished, with nothing to say it failed +- Reading back through a turn while it streams no longer throws your position toward the top of the chat +- A sub-agent no longer shows up twice, once in the sub-agents track and once in background tasks +- Commands an agent runs on Windows no longer lose part of the system PATH, which broke git hooks and npm scripts inside agent sessions +- Cost estimates cover Fable 5, Opus 4.5 and Sonnet 4.5 +- A chat, personality or team saved against a "1M" model id keeps its model features instead of silently losing them + +## 0.7.1 - 2026-07-27 + +### Added + +- Images an agent produces are kept under a retention policy and swept automatically once they age out or the store grows too large +- A Storage section in Settings reports what stored images and the preview cache are using, and can clear either +- The file pane has a real image viewer, with fit-to-window, zoom and actual size +- Binary and image files show a facts row in the file pane instead of an empty pane +- Selecting a row in Context Management shows the assembled text that section will actually send +- A section Otto cannot read because the provider composes it internally now says so rather than reading as empty +- Preview attaches to a dev server already running on a configured port instead of reporting it as an error + +### Changed + +- Auto-speech keeps reading a reply after you switch to another chat +- Spoken replies keep playing at full speed while the app sits in the background +- The workspace tab row attaches to a preview server that is already up rather than asking for a new one +- Quitting the desktop app stops its daemon on new installs, and the previous behaviour is one toggle away +- Importing an existing agent session from a chat lands in that workspace instead of one guessed from its directory +- The editor's code area sits on the same surface as code quoted in chat + +### Fixed + +- Images in chat no longer render at zero size +- The browser pane draws the same auto-hiding scrollbar as the rest of the app, instead of a permanent one that shifted the page it was previewing +- Naming a new chat falls back to that chat's own model, so a host with a single provider configured gets titles + +## 0.7.0 - 2026-07-25 + +### Added + +- Jump to a symbol's definition, see its type on hover, find every reference, and rename it across a project +- Problems from your language server and your linter show up in the editor gutter and in a diagnostics panel +- Refine rewrites the prose in your documents as a proposal you read and accept before anything is written +- Create, rename, delete and move files from the file explorer +- The Solution lens shows a .NET solution the way the build system sees it, with each project's real file set +- Agent personalities remember what they learn, and you can read, edit and transfer those lessons +- A Context Management tab shows everything filling an agent's context window and what each part costs +- Agents can put a small interactive widget straight into the conversation +- Mermaid diagrams render on every surface and platform +- AsciiDoc files render as formatted documents +- Images referenced by a relative path now appear in rendered markdown and AsciiDoc +- Delete a chat you have archived, or clear all of them at once +- A metrics bar above the chat shows what the conversation has cost so far +- Queue messages while an agent is working and they arrive together on its next turn +- Start a project from scratch on a new-project page +- A Metrics screen readout for how the app itself is using memory, timers and network +- Choose how many workspaces stay loaded in the background +- Send feedback from inside the app +- A font-contrast control for how strong the reading ink is +- Spoken replies queue up and play in order, with pauses where a voice would take them +- A volume slider for spoken replies, on its own audio channel +- Orchestration nodes declare what they can do and which workspace they may reach +- Pick a per-worktree base branch for the Changes view + +### Changed + +- Moving between workspaces is faster — the app stops re-asking the host for state it already has +- The Changes view resolves its base at the fork point, so a busy base branch no longer inflates the diff +- Speech reads notation as words and rests at the marks a voice does not say +- Cost is reported by the provider or shown as blank, never estimated from a rate table + +### Fixed + +- The composer no longer keeps a Features button it has no room for +- Auto-speech no longer re-reads the previous reply when you send a new message +- Voice mode keeps its live indicator when the message row it belongs to is redrawn +- Checking pull-request status no longer overwrites git state right after a commit +- The editor's diagnostic gutter stays right of the line numbers +- Hover signatures no longer paint a slab inside the tooltip +- Cancelling an orchestration run now stops the children it started +- Widgets load correctly in the desktop app + +## 0.6.7 - 2026-07-21 + +### Added + +- Reopen a worktree you archived earlier from the workspace list, recreated from its branch if the folder is gone +- Archiving a worktree offers to delete the branch it sat on, saying whether it is fully merged and how many commits deleting would discard +- A playback button on each agent message reads that message aloud +- The agent's task list is now an open checklist that shows which item is in progress +- Pin the task list above the chat so it stays visible while the agent works, and it closes itself once every task is done +- Search inside a file you are previewing, with case, whole-word, and regular-expression matching +- Open a worktree's base checkout from the workspace menu +- Workspace tabs that no longer fit collapse into an overflow menu instead of being cut off + +### Changed + +- Spoken replies start playing sooner in voice mode + +### Fixed + +- Voice mode recovers on its own when the microphone stops capturing audio +- In voice mode the agent no longer repeats its spoken reply as message text + +## 0.6.6 - 2026-07-20 + +### Added + +- Investigate any file through git: which commits touched it, what each one changed, and who created it +- Blame shows who last wrote each line of the file you are reading +- The Visualizer can run as a small picture-in-picture viewport pinned over your workspace, so the graph stays glanceable while you work +- Drag the picture-in-picture anywhere and pick between two sizes +- Workspace scripts now run as real terminal tabs with their own environment +- Agent voice cues are their own feature with a toggle and volume in Agents settings, and they keep working when the Visualizer is turned off +- A speech button in the workspace header silences voice cues without opening settings, separately from the setting that turns them off for good +- Three new working-indicator text effects: Wave, Flames, and Matrix +- Detail cards above the composer now rise from behind the message box and sink back down when they close +- A setting hides "merge into base branch" for pull-request-only workflows +- The Visualizer's context readout can be a ring hugging the node instead of a segmented bar +- The Visualizer gained a toolbar for its display and sound options + +### Fixed + +- The composer no longer drops the keystroke that wraps a line +- Starting a chat with a team's default personality no longer falls back to a random base model with no personality applied +- The Linux desktop icon is no longer blurry, now shipping sizes up to 512px +- Each personality gets its own distinct voice cue lines instead of every agent sounding the same +- Asking an agent to suggest a task now actually produces one +- The Visualizer fills its pane on small graphs instead of leaving most of the frame as margin +- Splitters follow the pointer from the first pixel rather than sticking and then jumping +- Dragging the vertical tab rail no longer stutters while it resizes +- The workspace tools row shows its labels as soon as they fit instead of waiting for a much wider sidebar +- Bundled third-party agents Factory Droid, fast-agent, and GLM are pinned to their current releases + +## 0.6.5 - 2026-07-20 + +### Added + +- Provider settings remember endpoints you have used before, so pointing a provider back at an earlier URL is one pick instead of retyping it +- The editor gained a status bar showing the file's language, cursor position, size and line endings +- A configurable ruler column marks the line-length limit behind the editor text +- Opening a browser tab or a preview while Browser Tools is off now explains the host setting and offers to turn it on +- The website offers the macOS desktop builds that releases have been shipping +- Visualizer voice cues are on by default + +### Fixed + +- Manage context is reachable from every workspace row and from the workspace menu, where before it only appeared on workspaces that had setup commands configured +- The Context tab reports on a workspace you have not started a chat in yet, instead of coming up empty +- The Context tab shows a loading state while it scans your context files +- Re-opening the Context tab paints the last report straight away rather than starting blank +- A context scan that fails now says what went wrong instead of looking like an empty workspace +- The message box uses your active team's personality on a fresh install instead of starting with none +- Toggle tooltips and the help dialog show your remapped shortcut rather than the default +- macOS no longer prompts for a desktop update it cannot install + +### Improved + +- Modals and sheets scroll consistently, with matching edge fades and multiline inputs throughout +- Links open in the in-app browser tab by default +- Agent teams and personality selection got a pass over their settings sections +- The desktop updates panel and the project settings screen were tidied up +- Bundled ACP agents (dimcode, Factory Droid, Nova and Qoder) move to their latest published versions + +## 0.6.4 - 2026-07-19 + +### Added + +- A new Context tab accounts for everything sent to the agent before you type — context files, memory, skills, MCP tools, and Otto's own prompt — read as a share of the model window rather than a bare token count +- The Context tab's "worth fixing" list takes you straight to the lines a finding is about +- A warning above the message box when fixed context takes a large share of the window, with its own Settings toggle +- Usage & cost gains a Log tab: an itemized ledger of every agent's tokens and cost, grouped under the chat turn that spawned it +- The usage Summary now shows real provider cost beside tokens, split across main chat, generations, sub-agents, and compaction +- A Reset button clears your usage counters and ledger, behind a confirm +- View the generation chat behind an artifact, or a schedule's last run, as a read-only transcript +- Otto Tools and Browser Tools move to their own Tools section in host settings, with a row per tool group +- Page transitions are animated, with a toggle to turn them off +- The Visualizer can be turned off entirely +- Unsigned macOS desktop builds are now attached to releases ([#4](https://github.com/Draek2077/otto-code/pull/4) by [@kerv](https://github.com/kerv)) + +### Improved + +- Sub-agent token and cost figures are now measured per sub-agent at any nesting depth, and a parent's cost no longer double-counts its children +- The rate-limit warning is now a fly-out above the message box that names your actual provider and stays dismissed until the limit escalates +- Documents render embedded HTML instead of printing raw tags, so a README's centered headings and badges look the way they do on GitHub +- An image that can't be shown falls back to its alt text, and previewing a repo document can no longer reach the network +- The Context tab's sidebar drags to any width and remembers it +- Speech settings move onto the Agents page, split into Dictation, Voice, and OpenAI sections +- New artifacts and schedules default to your team's Artificer and Scheduler +- Otto's own scrollbar now appears on mobile web and the open-project screen instead of the platform's +- Screens hold their fade until the panes actually mount, rather than revealing a half-built view +- Otto now credits both projects it builds on — Paseo and Agent Flow — and sends support to both + +### Fixed + +- The warning bands above the message box were never actually tinted — they painted a plain background in every theme +- Replayed Visualizer history no longer collapses into a single instant +- Clearing a chat no longer leaves the Visualizer stuck on the archived one +- A new chat appears in the Visualizer before you send the first message +- Pressing play in the Visualizer at the end of a run replays it from the start +- The Visualizer's star field no longer collapses into a thin column when a shrunk pane grows back +- Viewing documentation for a project you already have open now shows the README instead of an error +- Every session no longer starts with a run of empty bookkeeping entries at the top + +## 0.6.3 - 2026-07-17 + +### Added + +- Chats now get a short AI-generated title from your first message, replacing the placeholder first-line title +- After a turn, the agent's predicted next prompt appears as ghost text in the message box — press Tab to accept (Claude agents), with a new Settings toggle +- A warning strip above the message box when your Claude plan usage nears or hits a rate limit, with a new Settings toggle +- Press Up and Down in an empty message box to recall messages you've already sent +- The personality editor is now organized into Identity, Personality, Model, and Voice tabs +- Voice cues let a personality speak a short line when its agent joins, first starts thinking, and finishes — write them yourself or generate with AI, off by default +- Choose how a suggested task starts by default — New chat, Sub-agent, Worktree, or In session +- Pick the shape of Visualizer agent nodes — hexagon, square, octagon, or circle +- A new Visualizer toolbar collects its controls at the top of the tab +- An FPS meter toggle for the Visualizer, and a Gradient toggle for chat message bubbles in Appearance settings + +### Improved + +- Press Escape once to clear a typed-but-unsent message, and again to cancel the running agent +- Tool calls now show a single friendly name everywhere they appear +- Clicking a file link in chat opens it in a side pane instead of taking over your conversation +- Responses stream in with a smoother typewriter reveal and a live token count for the turn +- Completed sub-agents can auto-clear from a chat's track once they settle, with their token totals still counted in the header +- The Visualizer's per-node glow and full-scene bloom are now independent toggles +- A Claude Workflow run breaks out into one Visualizer row per agent it spawns +- Visualizer file paths now display relative to the agent's folder +- New chats no longer open inside the Visualizer pane +- The model picker groups your personalities into a drill-down submenu +- Refreshed the bundled versions of several third-party coding agents + +### Fixed + +- Sub-agent nodes in the Visualizer now settle correctly at the end of a run instead of lingering +- Archiving a chat now clears its Visualizer session + +## 0.6.2 - 2026-07-16 + +### Added + +- A Load demo scenario button in the Visualizer lets you preview the graph without live agents + +### Improved + +- On machines without GPU acceleration, the Visualizer automatically turns off its expensive bloom glow +- Hiding the Visualizer HUD now hides only the top and control bars, keeping the info panels available +- Agents spawned by another agent appear as child nodes in the Visualizer instead of separate session tabs +- Idle agents in the Visualizer now show as resting instead of endlessly thinking +- The sub-agents panel header now counts active and completed sub-agents and their total tokens +- Sub-agent rows lead with the agent's personality name next to the chat title +- Visualizer startup failures are recorded in the desktop log for troubleshooting +- Installing a desktop update now restarts the app right away, without an extra confirmation +- Pane splitters are slightly easier to grab +- The browser's responsive-mode button uses a clearer devices icon +- Explorer tabs show clearer hover feedback +- Refreshed the bundled versions of several third-party coding agents + +### Fixed + +- A Visualizer that can't start now shows an explanatory message instead of a silent blank tab +- The Visualizer timeline no longer shows an enormous timestamp +- Tools in the Visualizer no longer fade out while they are still running +- Subagent nodes in the Visualizer no longer flicker or spark repeatedly +- The Visualizer's bloom glow no longer flickers or looks washed out on light themes +- The Visualizer's top-bar count is labeled agents again, since it counts graph nodes +- Sub-agents no longer show up as "general-purpose" — rows are titled by the task they were given +- Cancelling the interrupt confirmation no longer collapses the message box over your unsent text +- Bitbucket pull requests report their state correctly again +- Launching the desktop app with graphics troubleshooting flags no longer drops it into command-line mode + +## 0.6.1 - 2026-07-16 + +### Added + +- The Visualizer now plays sound effects for agent activity — spawns, tool calls, completions, and errors — at half volume by default +- Set the Visualizer sound level with a new volume slider in Settings +- The speaker button in the Visualizer now remembers your mute choice across restarts +- A new button in the Visualizer hides the whole overlay, leaving just the animated graph + +### Improved + +- The Visualizer renders sharper by default, with less on-screen clutter +- Long chat titles no longer crowd out the Visualizer's session tabs +- A Visualizer sharing a split with your chat keeps animating while you type +- Background Visualizer tabs no longer use CPU or GPU +- Quitting the desktop app asks once in a single dialog, even when the schedules warning applies +- Refreshed punctuation and wording across the app +- Refreshed the bundled versions of several third-party coding agents + +### Fixed + +- Finished subagent tasks now fade out of the Visualizer instead of staying lit forever +- Agent name labels in the Visualizer no longer wobble in time with the node's pulse +- The quit confirmation no longer hangs for several seconds while it checks for enabled schedules +- An error no longer appears at startup on web and desktop from the QR pairing camera +- The Developer Tools menu item no longer appears in packaged desktop builds + +## 0.6.0 - 2026-07-16 + +### Added + +- Visualizer: a live, interactive map of what your agents are doing — agents, subagents, tool calls, messages, and a file-attention heatmap — that works for every provider and opens from any chat or scoped to a single run +- Text Effect themes: choose the animated style that sweeps across activity labels while an agent is working, in Appearance settings + +### Improved + +- Typing in the composer stays smooth while an agent is streaming its response +- The Stats screen fits its tiles to the window width and fills the screen, instead of leaving one row of small squares +- Bitbucket pull requests now show the Bitbucket icon and link straight to the pull request, instead of a GitHub glyph and a broken checks link +- Refreshed the bundled versions of several third-party coding agents + +### Fixed + +- Creating a second workspace on a folder already backing a live one is now blocked, so branches and diffs no longer silently interfere +- Review comments no longer carry onto an unrelated diff after you switch branches +- Commit, push, PR, and merge actions are locked while a branch switch is in progress, and can no longer fire twice mid-switch +- The setup wizard no longer freezes forever when a saved host is offline +- Leaving project settings with unsaved edits now warns instead of silently discarding them +- A saved personality whose role changed is no longer dropped from the picker +- The desktop app no longer shows a "warn before quitting" prompt when that option is turned off +- Stats tiles no longer flash when you switch the time window +- The chat context meter shows the correct color in the dark chat view +- The agent activity glow is no longer clipped on Android +- The drag preview keeps up when you drag panels quickly +- Branch names no longer render misaligned on Linux +- The marketing website builds correctly from worktree checkouts + +## 0.5.8 - 2026-07-14 + +### Added + +- Preview any file on your machine, including files outside your open projects, with out-of-project editing gated behind project links +- Code blocks without a language tag now get syntax highlighting through automatic language detection + +### Fixed + +- Otto launches reliably on virtual machines without 3D acceleration, which previously left it running with no visible window +- The local daemon starts cleanly instead of spawning runaway background processes when its port is already in use + +## 0.5.7 - 2026-07-14 + +### Fixed + +- The Linux desktop app failed to launch after installing the .deb or .rpm package, aborting with a Chromium sandbox error + +## 0.5.6 - 2026-07-14 + +### Added + +- Open and edit a file from another project without leaving your current workspace, once you've linked the two projects +- Agents can suggest follow-up tasks as chips, and you can start each one in its own chat, a local run, or a fresh worktree with a tap +- Workflow fan-out now shows up as read-only subagent rows you can watch, alongside Task subagents + +### Improved + +- Opening a workspace is faster and no longer pauses to fetch pull-request status up front +- Discarding file changes now warns and holds when an agent is working in that folder, matching how committing already behaves +- The desktop app recovers on its own when your local host restarts, instead of getting stuck on a missing host +- Personality picking now behaves consistently across the composer, artifacts, and schedules +- Refreshed the bundled versions of several third-party coding agents + +## 0.5.5 - 2026-07-14 + +### Added + +- Schedules can now stop after a set number of runs, or keep running forever +- Schedules remember and show which agent personality, provider, and model last ran them +- Artifacts record the agent personality that generated them and show it on the card + +### Improved + +- "Team's Role" slots wear a neutral role glyph across the personality, model, artifact, and schedule pickers, so it's clear you're picking a role rather than a specific agent + +## 0.5.4 - 2026-07-14 + +### Added + +- Teams can orchestrate multi-phase work on their own, with a new Runs view to watch each orchestration +- New Stats screen surfaces at-a-glance activity counters for your host +- Agents can start background tasks you can monitor, stop, or clear without leaving the chat +- Optional vertical tab rail for each pane, switchable in Appearance settings + +### Improved + +- Spawning a personality is now frictionless, with role tiers applied on every spawn path +- The daemon reaches Windows clients automatically when running under WSL, with no manual network setup +- More resilient Linux desktop startup with a software-rendering fallback, AppArmor profile, and crash dialog + +## 0.5.2 - 2026-07-13 + +### Added + +- Guided first-time setup that detects your providers, picks an interface style, and sets up a starter set of agent personalities and teams +- Agent teams — group personalities into switchable operating templates and flip between them from the sidebar +- User mode — a simplified interface that hides developer panels, with a Files-only explorer you can switch out of anytime + +### Improved + +- Scheduled and background runs now deny anything not pre-approved instead of running with full permissions +- Stop or archive a subagent straight from its row in the subagents track +- Finished subagents collapse into their own group, and you can clear them all at once +- Subagent rows show their running time and token cost at a glance +- Clearer notifications + +## 0.5.1 - 2026-07-12 + +### Added + +- Commit changed files straight from the Changes panel, choosing which to include +- New Git Log tab with commit history, scrollable on desktop web +- Roll back individual files from the Changes view +- AI commit messages come from a matching Writer personality + +### Improved + +- Mobile Git settings polish + +### Fixed + +- Explorer, sidebar, and Git chrome scale correctly on compact and mobile layouts +- No white flash when switching between workspaces +- Header Git actions stay hidden for non-Git workspaces +- Regular Git checkouts no longer show an archived workspace as primary +- Clearer fuzzy project search + +## 0.5.0 - 2026-07-11 + +### Added + +- Agent personalities — reusable per-host templates (provider, model, effort, mode, prompt, roles, colors, voice) +- A starter team of six personalities on every new host, restorable anytime +- Running agents show their personality's name, icon, and colored spinner +- Switch a running agent's personality from its model picker +- Bitbucket Cloud support for PRs and issues, alongside GitHub +- Voice & dictation settings in Host settings, with new Kokoro v1.0 voices +- Live turn stats — elapsed timer and token count per turn +- Switchable exact/relative chat timestamps +- Pinnable Changes toolbar controls +- Right-click menus on sidebar rows (desktop) +- Drag to resize the settings sidebar +- Agents can manage their own artifacts + +### Improved + +- Assistant replies stream in with a smooth typewriter reveal +- One consistent "Effort" control across every provider +- Risk-color-coded agent mode picker +- Flatter schedule form +- Slightly lighter dark themes +- Explorer tabs show labels when there's room +- Regenerating an artifact keeps the last good version on failure +- Smoother native text-to-speech playback +- Polish across sidebar, explorer, headers, chat, Schedules, and Artifacts + +### Fixed + +- Desktop tabs row no longer goes missing when opening a workspace by link +- Correct window-control chrome on Windows/Linux desktop +- Sheets and popovers over the title bar are clickable again +- Mobile bottom sheets fit their content +- Home page content is optically centered + +### Security + +- Bitbucket auth is per-request and never logged; merges re-check preconditions on the daemon + +## 0.4.4 - 2026-07-10 + +### Added + +- Open and edit files in a workspace tab, with live preview and split view +- Jump to any symbol or line in a file +- Select code and ask an agent to refactor it +- Jump to any file by name +- Project-wide search and replace, with a large-replace warning +- Checkable task lists in markdown files +- "Find in files" reveals the file in the Files tree +- Add a file to the conversation from the Changes view +- Add an artifact from the mobile workspace menu + +### Improved + +- Provider settings split into Connection, Models, and Tools tabs +- The search shortcut focuses the search box + +### Fixed + +- Mobile Features toggles show their labels clearly + +## 0.4.3 - 2026-07-09 + +### Added + +- Agents can create artifacts mid-conversation +- Claude subagent tasks show as their own watchable rows +- Buttons to expand or collapse all sidebar groups +- Chat groups a run of actions into one collapsible summary +- Pin pane tab tools so favorites stay visible +- Auto-compact for OpenAI Compatible providers +- New Schedules card layout with a project filter and Failed tab + +### Improved + +- OpenAI Compatible providers resume with full history +- OpenAI Compatible providers connect to MCP servers in parallel +- Faster workspace switching, no blank flash +- Scripts button follows the workspace tools setting +- More compact, clearer sidebar rows +- fast-agent updated to 0.9.4 + +### Fixed + +- Scroll-to-bottom button no longer blocks nearby clicks +- New terminals focus the pane you clicked +- Black chat background no longer bleeds into the top bar on web +- Scrolled chat no longer breaks title-bar dragging +- Download page drops builds this fork doesn't provide + +### Security + +- OpenAI Compatible web fetch asks permission except in full auto-approval +- Stronger DNS-rebinding and internal-address protection +- Auto-approved edits stay inside your workspace folder +- Fixed mishandling of characters like `$1` in replacement text + +## 0.4.2 - 2026-07-08 + +### Added + +- New Artifacts screen to generate and organize shareable HTML docs +- Artifacts open as tabs you can watch, cancel, or regenerate +- Optional confirmation before quitting with active sessions +- Confirmation before archiving a stopped chat +- "Web search" toggle for OpenAI Compatible providers + +### Improved + +- Bolder sidebar footer icons with tooltips; "New project" label +- fast-agent updated to 0.9.3 + +### Fixed + +- OpenAI Compatible `/compact` no longer over-collapses long conversations +- The desktop title bar can be dragged to move the window +- Linux deb/rpm installs put the `otto` CLI on PATH automatically + +### Security + +- OpenAI Compatible web fetch can't reach localhost or private networks + +## 0.4.1 - 2026-07-06 + +### Added + +- "Black tab background" option in Appearance +- `/compact` for OpenAI Compatible providers + +### Improved + +- Composer keeps the mode selector and context ring inline at any width +- Composer buttons shrink together on narrow screens +- Font size uses a slider +- Brain icon for reasoning effort in the composer +- New working indicator — two orbiting lights, themed +- Live context usage during a turn for OpenAI Compatible providers +- fast-agent updated to 0.9.2 + +### Fixed + +- Clearing an agent no longer stops dev servers on the same port +- Toasts from bottom sheets no longer crash the app + +### Security + +- OpenAI Compatible agents ask before running Otto's built-in tools +- Stopping a preview server only works for recognized workspace servers + +## 0.4.0 - 2026-07-06 + +### Added + +- OpenAI Compatible agents can connect to MCP servers +- OpenAI Compatible agents support reasoning effort and conversation rewind +- MCP prompts appear as composer slash commands +- Context usage ring with a breakdown, persisted across restarts + +### Changed + +- Local-endpoint preset renamed "OpenAI Compatible" (was "LM Studio") +- otto-code.me adds Preview features and Local models pages + +### Fixed + +- Home screen links no longer overlap on short screens +- Composer Stop button icon shows again +- Mobile chat streaming no longer jitters +- Sending on mobile dismisses the keyboard +- No more duplicate diff count in the workspace list +- Consistent icon and text scaling on compact layouts + +## 0.3.3 - 2026-07-05 + +### Changed + +- Redesigned otto-code.me landing and sponsor pages +- fast-agent updated to 0.9.1 + +### Fixed + +- Windows/Linux desktop updates publish even if the macOS build fails +- Web app deploys and CI pass on this fork again + +## 0.3.2 - 2026-07-05 + +### Fixed + +- Windows and Linux desktop installers are available to download + +## 0.3.1 - 2026-07-05 + +### Changed + +- Desktop downloads and updates come from this fork's own release page + +## 0.3.0 - 2026-07-05 + +### Changed + +- Otto now versions independently, starting at 0.3.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..591cc5565 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,139 @@ +# CLAUDE.md + +## Why this fork exists + +This repo (otto-code) is a fork with one mission: extend Otto into a **fully featured agentic coding assistant** — an IDE-grade environment with a rich feature set, familiar enough that you never feel constrained, that brings **frontier-model tooling to every provider equally, cloud and local alike**. The tooling a frontier harness gives its own model — browser-verified previews, artifacts, subagent visibility, context compaction, permission modes, MCP — should be just as available to a local model served from LM Studio as to a hosted frontier API. A capability isn't done when one provider has it; it's done when they all do. + +The founding proof was the **Preview subsystem** — a rebuild of the Claude Code app's built-in `Claude_Preview` MCP server, shipped for all providers: agents start dev servers from a launch config, then verify browser-rendered changes (accessibility snapshots, DOM inspection, console/network capture, click/fill, viewport resize, screenshots), showing proof instead of asking the user to check manually. Read [docs/preview.md](docs/preview.md) before working on anything preview-related — it carries the design principles that must survive future changes (token economy, guardrail-bearing tool descriptions, daemon-enforced tab binding). The dev-server half lives in `packages/server/src/server/preview/`; the verification half is the daemon's browser-tools subsystem (`packages/server/src/server/browser-tools/`) executing against the Otto browser pane. Extend these; don't build a parallel browser stack. + +The same leveling-up pattern has since shipped artifacts, the natively-tooled OpenAI-compatible provider (daemon-owned tool loop, MCP client, compaction, rewind), observed subagents for Claude, a provider-neutral git-hosting layer (GitHub + Bitbucket Cloud, see [docs/git-providers.md](docs/git-providers.md)), and agent personalities (named per-host templates with roles, spawnable by orchestrating agents, see [docs/agent-personalities.md](docs/agent-personalities.md)) — with the remaining per-provider gaps tracked as initiatives in `projects/`. When adding a capability, design it provider-agnostic first and treat single-provider support as the proof, not the finish line. + +## Repository map + +`test-documents/` (repo root) holds hand-authored, self-contained fixtures for the file viewer — one per supported format, covering syntax highlighting and rendered previews. See [test-documents/README.md](test-documents/README.md). It is excluded from oxlint and oxfmt: the varied formatting is the point. + +## Documentation + +Four trees. Know which one you are in before you write anything down. + +| Tree | What it holds | Tense | +| ------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------- | +| **[`docs/`](docs/README.md)** | The official software documentation — how Otto works. **This is the spec we build against.** | Present | +| **[`projects/`](projects/README.md)** | Charters for unbuilt work, and **the single open-work ledger** | Future | +| [`archdocs/`](archdocs/README.md) | The system-level architecture record — AsciiDoc + Mermaid, one level above `docs/` | Present, wide-angle | +| **This file** | Working rules for agents in this repo | Imperative | + +**The indexes are [`docs/README.md`](docs/README.md) and [`projects/README.md`](projects/README.md). +This file does not duplicate them** — it used to carry both tables, and two copies of an index means +one of them is wrong. At the start of non-trivial work, open the relevant index and skim what +matters. + +**"The docs", "check the docs", or "check the X docs" always mean `docs/` — not the web.** Look there +before fetching anything online; it captures gotchas and conventions you cannot derive from the code +or from external sources. + +`public-docs/` is the user-facing manual published to otto-code.me. Different audience, different +contract — it documents what Otto does, not how it is built. Do not put engineering notes there. + +### Read these before touching the matching area + +Non-negotiable. Each one exists because someone got it wrong first. + +| Before you touch… | Read | +| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Anything preview- or browser-tool-related | [docs/preview.md](docs/preview.md) — token economy, guardrail-bearing tool descriptions, daemon-enforced tab binding, **per-workspace scope (chats trample each other), and always prefer the running server** | +| App routes, startup routing, remembered-workspace restore, active-workspace selection | [docs/expo-router.md](docs/expo-router.md) | +| Styling | [docs/unistyles.md](docs/unistyles.md) — `useUnistyles()` is forbidden; [docs/hover.md](docs/hover.md); [docs/design.md](docs/design.md) | +| WebSocket schemas or validation | [docs/protocol-validation.md](docs/protocol-validation.md) and [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | +| Go-to-definition, hover, references, rename, diagnostics | [docs/code-intelligence.md](docs/code-intelligence.md) | +| Anything that rides in a model request | [docs/token-economy.md](docs/token-economy.md) — the five structural multipliers | +| Fixed context weight, the context graph, or the Context Management tab | [docs/context-management.md](docs/context-management.md) — hard vs soft edges, % of window as the severity unit | +| Refine, or widening what it may rewrite | [docs/refine.md](docs/refine.md) — **prose only**: no parser, no symbol table, `refine-scope.ts` is the one gate | +| App Playwright E2E, or adding a spec | [docs/testing.md](docs/testing.md) — the three tiers, and the coverage matrix a spec must be added to in the same change | +| Marketing-site or store captures | [docs/site-demos.md](docs/site-demos.md) — the whole-frame rule, the gotchas ledger, and the resolution/zoom trap | +| Terminology in UI copy | [docs/glossary.md](docs/glossary.md) — the UI label wins, no synonyms | +| Website copy, `public-docs/`, release notes, marketing drafts | [docs/writing-style.md](docs/writing-style.md) — **never use em-dashes in prose**, the five replacements, first-person voice | +| A new agent provider | [docs/providers.md](docs/providers.md) | + +### Where new knowledge goes + +- **Code-level facts** → inline comments next to the code. +- **System, process and gotcha-level facts** → a page in `docs/`, **and a row in + [`docs/README.md`](docs/README.md)**. An unlisted page is an invisible page. +- **Point-in-time plans** (a feature build-out, a charter, a refactor plan) → `projects//`, + one folder per initiative, **and a row in [`projects/README.md`](projects/README.md)**. +- **Status — what is done and what is not** → [`projects/README.md`](projects/README.md) only. It is + the single source of truth. Do not start a second registry, a findings file, or a dated batch + document; that is how four competing ledgers happened last time. +- **An external source that shaped a decision** → [`docs/references.md`](docs/references.md), + including sources you evaluated and rejected. + +### When a project ships + +Fold its durable facts into the relevant `docs/` page, move any remaining tail into +[`projects/README.md`](projects/README.md), then **remove the folder**. A shipped project left in +`projects/` is the most common way that tree rots. + +Removed folders move to `archive/` at the repo root, which is **gitignored** — out of the repo, still +on disk. Nothing there is active work; do not pick items up from it. If something is genuinely dead, +delete it rather than archiving it. + +## Quick start + +Scripts live in the root `package.json`. The two whose invocation is not guessable: + +```bash +npm run cli -- ls -a -g # List all agents +npm run cli -- daemon status # Check daemon status +``` + +**Dev and the installed app are fully isolated, and are meant to run at the same time.** Every repo dev command resolves through `scripts/dev-home.{sh,ps1}` to the dev daemon on port `6788` and the checkout-local `OTTO_HOME` at `packages/desktop/.dev/otto-home` — including `npm run cli -- ...`. The installed desktop app and its daemon keep `~/.otto` on port `6868` and are never touched. Never hardcode `6868` into a dev script or a launch config; that is the installed app's port, and landing on it either crash-loops the dev daemon or silently points dev clients at production agents. + +See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging. + +## Critical rules + +- **NEVER restart the main Otto daemon on port 6868 without permission** — that is the installed app's daemon over `~/.otto`, it manages all running agents, and if you're an agent, restarting it kills your own process. The dev daemon on `6788` is the one you may restart freely. +- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. +- **NEVER add auth checks to tests** — agent providers handle their own auth. +- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).** +- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules: + - Run only the specific test file you changed: `npx vitest run --bail=1` + - Never run `npm run test` for an entire workspace unless explicitly asked. + - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. + - Never re-run a test suite that another agent already ran and reported green — trust the result. + - For full suite verification, push to CI and check GitHub Actions instead. +- **Always run typecheck and lint after every change.** +- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current: + - `npm run build:client` — rebuild protocol and client declarations. + - `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale. + - Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors. +- **Run `npm run format` before committing.** This repo uses oxfmt for formatting (oxlint for linting). Do not manually fix formatting — let the formatter handle it. +- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script: + - `npm run lint -- packages/app/src/components/message.tsx` + - `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx` +- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts: + - **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client. + - New fields: `.optional()` with a sensible default. + - Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass. + - Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled. + - `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers. + - Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null). + - Removed fields stay accepted (we stop sending them, not stop reading them). + - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" + - **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it. + - **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature. + - **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape. + - **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site. + - Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal. + - **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones. + +- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable. + +## Platform gating + +See [packages/app/CLAUDE.md](packages/app/CLAUDE.md). It loads automatically when you work under `packages/app`, and covers the four gates (`isWeb`, `isNative`, `getIsElectron()`, `useIsCompactFormFactor()`), Metro `.web`/`.native`/`.electron` file resolution, and why hover does not fire on native. + +## Debugging + +Find the complete daemon logs and traces in the $OTTO_HOME/daemon.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82434146c..40c656359 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,43 +1,55 @@ -# Contributing to Agent Flow +# Contributing to Otto -Thank you for your interest in contributing to Agent Flow! This document provides guidelines for contributing to the project. +Otto is an opinionated product maintained by one person right now. -## Contributor License Agreement (CLA) +The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups. -Before your first contribution can be merged, you must sign the [Contributor License Agreement](CLA.md). When you open your first pull request, the CLA Assistant bot will guide you through the process. You only need to sign once. +Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. -## How to Contribute +## Becoming a maintainer -### Reporting Bugs +There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one. -- Open an issue describing the bug, including steps to reproduce -- Include your environment details (OS, VS Code version, Node version) +Here's the progression: -### Suggesting Features +1. Get involved in the community: answer questions in Discord and on GitHub +2. Triage bugs: replicate and help fix them +3. Work on maintainer-approved features -- Open an issue describing the feature and its use case -- Explain why this feature would be valuable to the project +The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer. -### Submitting Code +Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute. -1. Fork the repository -2. Create a feature branch from `main` (`git checkout -b feature/my-feature`) -3. Make your changes -4. Test your changes thoroughly -5. Commit with clear, descriptive messages -6. Push to your fork and open a pull request against `main` +## Pull requests -### Pull Request Guidelines +✅ Will be accepted -- Keep pull requests focused — one feature or fix per PR -- Provide a clear description of what the PR does and why -- Reference any related issues -- Ensure existing functionality is not broken +- Keep it to one focused change +- Link to an issue +- Explain the problem you're solving +- Include repro steps if it's a bug +- Include QA/testing evidence +- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web +- If you only tested one platform, say that clearly -## Code of Conduct +⛔️ Will be rejected -Be respectful and constructive in all interactions. We are committed to providing a welcoming and inclusive experience for everyone. +- Bundle unrelated changes +- Fail basic checks like typecheck, formatting or linting +- Add a feature or design change that wasn't discussed first +- Submit no evidence of testing +- Skip the linked issue -## License +## Requesting features -By contributing to Agent Flow, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE), subject to the terms of the [CLA](CLA.md). +If you need a feature implemented, create a Github issue or a thread in Discord. + +Explain the problem you want to solve: your use case, where Otto falls short today, and the flow you expect. + +## AI assistance + +Using AI to help write code is fine, but you must: + +- Ensure your agents read the docs +- Understand the code you submit +- Review and test the code yourself diff --git a/LICENSE b/LICENSE index 7edb0fe75..be3f7b28e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,199 +1,661 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to the Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by the Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding any notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. - - Copyright 2025 Simon Patole - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..a9a33f1d9 --- /dev/null +++ b/NOTICE @@ -0,0 +1,92 @@ +Otto +Copyright (c) 2026-present The Otto authors + +This product is a modified fork of the Paseo project. + + Paseo + Copyright (c) 2025-present Mohamed Boudra + Source: https://github.com/getpaseo + License: GNU Affero General Public License, version 3 (AGPL-3.0) + +================================================================================ +Attribution and license +================================================================================ + +Otto is derived from Paseo and is distributed under the same license, the GNU +Affero General Public License version 3 (AGPL-3.0), in accordance with the +copyleft terms of the original work. The original copyright notice is preserved +verbatim in this file (above), and the full, unmodified text of the license is +preserved verbatim in the LICENSE file at the root of this repository. Neither +is removed or altered by this fork. + +The scope statement that originally accompanied the copyright notice is +preserved here verbatim: + + Copyright (c) 2025-present Mohamed Boudra + + Portions of this software are licensed as follows: + + * All third party components incorporated into the Paseo Software are + licensed under the original license provided by the owner of the + applicable component. + * All content outside of the above mentioned restrictions is available + under the "AGPLv3" license as defined below. + +(That notice previously sat at the top of the LICENSE file. It lives here so +that LICENSE contains the AGPL-3.0 text and nothing else, which is what license +scanners — GitHub/licensee, SPDX tooling, npm, F-Droid, Flathub, and +distribution package metadata — require in order to identify the license +correctly. Relocating it changes nothing about the terms, the copyright holder, +or what is licensed; "as defined below" should now be read as referring to the +LICENSE file.) + +As required by section 5 of the AGPL-3.0 ("Conveying Modified Source Versions"), +this file provides prominent notice that Otto is a modified version of Paseo and +records the nature of the modifications at a high level. + +If you run a modified version of this software to interact with users over a +network, the AGPL-3.0 requires that those users be offered access to the +corresponding source code of your modified version. + +================================================================================ +Summary of modifications relative to Paseo +================================================================================ + +Otto begins as a rebrand of Paseo and is being developed toward a more +autonomous AI coding IDE. Changes so far include, but are not limited to: + + * Project, product, and package renaming from "Paseo" to "Otto" + (npm scope "@getpaseo/*" -> "@otto-code/*", CLI command "paseo" -> "otto", + environment-variable prefix "PASEO_" -> "OTTO_", default data directory + "~/.paseo" -> "~/.otto", default daemon port 6767 -> 6868, marketing domain + paseo.sh -> otto-code.me, application bundle identifiers "sh.paseo*" -> + "me.ottocode*" for the mobile app and "ai.ottocode.desktop" for the desktop + app). + * Associated documentation, configuration, and asset updates for the new name. + +Further functional changes made after the fork are recorded in the project's +version-control history and CHANGELOG. + +================================================================================ +Third-party components +================================================================================ + +All third-party components incorporated into this software remain licensed under +the original license provided by the owner of the applicable component, as noted +in the scope statement above and in the respective dependency manifests. + + Agent Flow + Copyright (c) Simon Patole + Source: https://github.com/patoles/agent-flow + License: Apache License, Version 2.0 + +Otto's "Visualizer" feature incorporates the render layer of Agent Flow, vendored +as a git subtree under vendor/agent-flow/ and driven by Otto's own event stream +rather than Agent Flow's own ingestion. As required by section 4 of the Apache +License 2.0, modifications carried against the vendored tree are enumerated with +rationale in vendor/agent-flow/OTTO-PATCHES.md, which serves as the notice of +state changes. The Agent Flow name and logos are trademarks of their owner and +are not used as Otto branding or as a user-facing feature label. + +Otto is an independent project. It is not endorsed by, sponsored by, or +affiliated with the Paseo project or its authors. diff --git a/README.md b/README.md index af79238cc..d01ec2267 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,341 @@ -# Agent Flow +

+ Otto logo +

+ +

Otto

+ +

+ + GitHub stars + + + GitHub release + + + GitHub issues + +

+ +

One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.

+ +> [!NOTE] +> **Otto is a modified fork of [Paseo](https://github.com/getpaseo)** (© 2025–present +> Mohamed Boudra), reworked toward an autonomous AI coding IDE. It remains licensed +> under AGPL-3.0. See [NOTICE](NOTICE) for full attribution and a summary of changes. + +

+ Otto app screenshot +

+ +

+ Otto mobile app +

+ +> [!NOTE] +> This is a one-person project run in spare time, and I'm obsessed with it; so if you message me you will get a reply instantly. +> [Open an issue](https://github.com/Draek2077/otto-code/issues), or provide feedback right in the app to my discord! + +--- + +## Why I'm building this + +I'm Philippe. Otto isn't a startup and I'm not trying to sell you anything — it's the +environment I want to work in, and the way I'm getting better at agentic coding. Most of +Otto is written by the agents Otto runs, which is either the point or the joke, depending +on the day. + +The problem I keep hitting: agents can now do an enormous amount of work on their own, and +it's genuinely hard to see what they did, what it cost, and where it went sideways. So the +work here leans toward **observability and accounting** — real per-subagent token and cost +numbers, a live visualizer of the orchestration graph, browser-verified previews so an agent +proves a change instead of just claiming it — and toward **pulling good open-source pieces +into one setup that actually works end to end**, instead of five tools that half-talk to +each other. + +That's the whole thesis: let AI do the autonomous work, but make the operation legible while +it happens. If that matches how you work, I'd like the help — issues, comments, and PRs all +welcome. + +--- + +Run agents in parallel on your own machines. Ship from your phone or your desk. + +- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. +- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job. +- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it. +- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. +- **Privacy-first:** Otto doesn't have any telemetry, tracking, or forced log-ins. -Real-time visualization of Claude Code and Codex agent orchestration. Watch your agents think, branch, and coordinate as they work. [Demo video here](https://www.youtube.com/watch?v=Ud6eDrFN-TA). +## Getting Started -![Agent Flow visualization](https://res.cloudinary.com/dxlvclh9c/image/upload/v1773924941/screenshot_e7yox3.png) +Otto runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it. -## Why Agent Flow? +### Prerequisites -I built Agent Flow while developing [CraftMyGame](https://craftmygame.com), a game creation platform driven by AI agents. Debugging agent behavior was painful, so we made it visual. Now we're sharing it. +You need at least one agent CLI installed and configured with your credentials: -Claude Code is powerful, but its execution is a black box — you see the final result, not the journey. Agent Flow makes the invisible visible: +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) +- [Codex](https://github.com/openai/codex) +- [GitHub Copilot](https://github.com/features/copilot/cli/) +- [OpenCode](https://github.com/anomalyco/opencode) +- [Pi](https://pi.dev) -- **Understand agent behavior** — See how Claude breaks down problems, which tools it reaches for, and how subagents coordinate -- **Debug tool call chains** — When something goes wrong, trace the exact sequence of decisions and tool calls that led there -- **See where time is spent** — Identify slow tool calls, unnecessary branching, or redundant work at a glance -- **Learn by watching** — Build intuition for how to write better prompts by observing how Claude interprets and executes them +### Desktop app (recommended) -## Features +Download it from [otto-code.me/download](https://otto-code.me/download) or the [GitHub releases page](https://github.com/Draek2077/otto-code/releases). Open the app and the daemon starts automatically. Nothing else to install. -- **Live agent visualization**: Watch agent execution as an interactive node graph with real-time tool calls, branching, and return flows -- **Claude Code + Codex**: Auto-detects sessions from both runtimes concurrently and shows them side-by-side, or restrict to one via the `agentVisualizer.runtime` setting -- **Claude Code hooks**: Lightweight HTTP hook server receives events directly from Claude Code for zero-latency streaming -- **Codex rollout tailing**: Reads `~/.codex/sessions/**/rollout-*.jsonl` (respects `CODEX_HOME`) and surfaces tool calls, reasoning, and authoritative token counts from Codex's own event stream -- **Multi-session support**: Track multiple concurrent agent sessions with tabs -- **Interactive canvas**: Pan, zoom, click agents and tool calls to inspect details -- **Timeline & transcript panels**: Review the full execution timeline, file attention heatmap, and message transcript -- **JSONL log file support**: Point at any JSONL event log to replay or watch agent activity +To connect from your phone, scan the QR code shown in Settings. -## Getting Started +### CLI / headless -### Quick Start (no VS Code required) +Install the CLI and start Otto: ```bash -npx agent-flow-app +npm install -g @otto-code/cli +otto ``` -This starts the visualizer in your browser. Start a Claude Code session in another terminal — events will stream in real-time. +This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines. -Options: -- `--port ` — change the server port (default: 3001) -- `--no-open` — don't open the browser automatically -- `--verbose` — show detailed event logs +For full setup and configuration, see: -### Standalone Web App (from source) +- [Docs](https://otto-code.me/docs) +- [Configuration reference](https://otto-code.me/docs/configuration) -```bash -git clone https://github.com/patoles/agent-flow.git -cd agent-flow -pnpm i -pnpm run setup # configure Claude Code hooks (one-time) -pnpm run dev # start the web app + event relay -``` +### Docker -Open http://localhost:3000 and start a Claude Code session in another terminal — events will stream to the browser in real-time. +Run the Otto daemon and self-hosted web UI in Docker: -### VS Code Extension +```bash +docker run -d --name otto \ + -p 6868:6868 \ + -e OTTO_PASSWORD=change-me \ + -v "$PWD/otto-home:/home/otto" \ + -v "$PWD:/workspace" \ + ghcr.io/draek2077/otto:latest +``` -1. Install the extension -2. Open the Command Palette (`Cmd+Shift+P`) and run **Agent Flow: Open Agent Flow** -3. Start a Claude Code or Codex session in your workspace — Agent Flow will auto-detect it +Open `http://localhost:6868` after it starts. Extend the base image with the agent CLIs you use, then provide credentials through environment variables or the persistent `/home/otto` volume. See the [Docker documentation](docs/docker.md) for full setup details. -Agent Flow automatically configures Claude Code hooks the first time you open the panel. To manually reconfigure, run **Agent Flow: Configure Claude Code Hooks** from the Command Palette. +## CLI -### Runtime selection +Everything you can do in the app, you can do from the terminal. -By default Agent Flow watches both Claude Code (`~/.claude/projects/`) and Codex (`~/.codex/sessions/`) concurrently in all three entry points (VS Code extension, `pnpm run dev`, `npx agent-flow-app`). Sessions are shown side-by-side and tagged by runtime. If you only use one, the other is a harmless no-op — no visible effect, no user action needed. +```bash +otto run --provider claude/opus-4.6 "implement user authentication" +otto run --provider codex/gpt-5.4 --worktree feature-x "implement feature X" -To restrict to one runtime: +otto ls # list running agents +otto attach abc123 # stream live output +otto send abc123 "also add tests" # follow-up task -- **VS Code extension:** set `agentVisualizer.runtime` to `"auto"` / `"claude"` / `"codex"` in your settings -- **`pnpm run dev` and `npx agent-flow-app`:** set the `AGENT_FLOW_RUNTIME` environment variable to `claude` or `codex` (defaults to watching both) +# run on a remote daemon +otto --host workstation.local:6868 run "run the full test suite" +``` -For non-default Codex installs, set the `CODEX_HOME` environment variable. +See the [full CLI reference](https://otto-code.me/docs/cli) for more. -### JSONL Event Log +## Skills -You can also point Agent Flow at a JSONL event log file: +Skills teach your agent to use Otto to orchestrate other agents. -1. Set `agentVisualizer.eventLogPath` in your VS Code settings to the path of a `.jsonl` file -2. Agent Flow will tail the file and visualize events as they arrive +```bash +npx skills add Draek2077/otto-code +``` -## Commands +Then use them in any agent conversation: -| Command | Description | -|---------|-------------| -| `Agent Flow: Open Agent Flow` | Open the visualizer panel | -| `Agent Flow: Open Agent Flow to Side` | Open in a side editor column | -| `Agent Flow: Connect to Running Agent` | Manually connect to an agent session | -| `Agent Flow: Configure Claude Code Hooks` | Set up Claude Code hooks for live streaming | +- `/otto-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement. +- `/otto-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier. +- `/otto-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself. +- `/otto-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan. -## Keyboard Shortcut +## Development -| Shortcut | Action | -|----------|--------| -| `Cmd+Alt+A` (Mac) / `Ctrl+Alt+A` (Win/Linux) | Open Agent Flow | +Quick monorepo package map: -## Settings +- `packages/server`: Otto daemon (agent process orchestration, WebSocket API, MCP server) +- `packages/app`: Expo client (iOS, Android, web) +- `packages/cli`: `otto` CLI for daemon and agent workflows +- `packages/desktop`: Electron desktop app +- `packages/relay`: Relay package for remote connectivity +- `packages/website`: Marketing site and documentation (`otto-code.me`) -| Setting | Default | Description | -|---------|---------|-------------| -| `agentVisualizer.runtime` | `"auto"` | Which agent runtime(s) to watch: `"auto"` (both), `"claude"`, or `"codex"` | -| `agentVisualizer.devServerPort` | `0` | Development server port (0 = production mode) | -| `agentVisualizer.eventLogPath` | `""` | Path to a JSONL event log file to watch | -| `agentVisualizer.autoOpen` | `false` | Auto-open when an agent session starts | +Common commands: -## Requirements +```bash +# run all local dev services +npm run dev -- [Node.js](https://nodejs.org/) 20+ (LTS recommended) -- [pnpm](https://pnpm.io/) -- Claude Code CLI -- For the VS Code extension: a VSCode-compatible IDE 1.85+ (e.g. [VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.sh/), [Windsurf](https://windsurf.com/)) +# run individual surfaces +npm run dev:server +npm run dev:app +npm run dev:desktop +npm run dev:website -## Development +# build the server stack +npm run build:server -```bash -pnpm i # install dependencies for all packages -pnpm run setup # configure Claude Code hooks (one-time) -pnpm run dev # start dev server + event relay +# repo-wide checks +npm run typecheck ``` -`pnpm run dev` starts both the Next.js dev server and an event relay that receives Claude Code events and streams them to the browser via SSE. +## Documentation + +Five trees, five audiences. This section is the entry point to all of them. + +| Tree | Audience | What it is | +| ------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **[`docs/`](docs/README.md)** | Anyone building Otto | **The official software documentation.** How Otto works — system design, subsystem behaviour, conventions, gotchas. This is the specification we build against | +| **[`projects/`](projects/README.md)** | Anyone planning Otto | Charters for work not yet done, plus **the single open-work ledger** — the one place that says what is done and what is not | +| **[`findings/`](findings/README.md)** | Anyone diagnosing Otto | Dated, reproducible investigation reports — what was measured, how, and what it ruled in or out. Evidence, not specification | +| **[`archdocs/`](archdocs/README.md)** | Architects | The system-level architecture record: AsciiDoc pages with Mermaid diagrams, one level above `docs/`. `npm run archdocs:serve` | +| **[`CLAUDE.md`](CLAUDE.md)** | AI coding agents | Working rules and constraints for agents in this repo. Deliberately **not** a documentation index — that is [`docs/README.md`](docs/README.md) | + +The three writing trees differ by **tense**: `docs/` is present (_this is how it behaves_), +`projects/` is future (_this is what we will build_), `findings/` is past (_this is what we +measured_). A dated measurement in `docs/` reads as a permanent fact, which is why it has its own +tree. + +User-facing product documentation is published at +[otto-code.me/docs](https://otto-code.me/docs) and authored in `public-docs/`. It documents what Otto +does; `docs/` documents how it is built. + +### Quick links into `docs/` + +**Start here** — [Product](docs/product.md) · +[Architecture](docs/architecture.md) · +[Glossary](docs/glossary.md) · +[Coding standards](docs/coding-standards.md) · +[Development](docs/development.md) + +**Chat, agents and orchestration** — [Chat lifecycle](docs/chat-lifecycle.md) · +[Agent personalities](docs/agent-personalities.md) · +[Agent teams](docs/agent-teams.md) · +[Subagent accounting](docs/subagent-accounting.md) · +[Orchestration node capabilities](docs/orchestration-node-capabilities.md) · +[Safe unattended runs](docs/safe-unattended.md) · +[Suggested tasks](docs/suggested-tasks.md) · +[Timeline sync](docs/timeline-sync.md) · +[Visualizer](docs/visualizer.md) · +[Activity stats](docs/activity-stats.md) · +[Terminal activity](docs/terminal-activity.md) + +**Workspaces, files and git** — [Workspace lifecycle](docs/workspace-lifecycle.md) · +[New project](docs/new-project.md) · +[Changes view](docs/changes-view.md) · +[Git providers](docs/git-providers.md) · +[Git file history](docs/git-file-history.md) + +**Editor and code intelligence** — [Text editor](docs/text-editor.md) · +[Code intelligence (LSP)](docs/code-intelligence.md) · +[Markdown rendering](docs/markdown-rendering.md) · +[File icons](docs/file-icons.md) + +**Providers and integration** — [Providers](docs/providers.md) · +[Custom providers](docs/custom-providers.md) · +[Preview](docs/preview.md) · +[Service proxy](docs/service-proxy.md) + +**Client and UI** — [Design tokens](docs/design.md) · +[Unistyles](docs/unistyles.md) · +[Hover](docs/hover.md) · +[Floating panels](docs/floating-panels.md) · +[Mobile panels](docs/mobile-panels.md) · +[Expo Router](docs/expo-router.md) · +[Forms](docs/forms.md) · +[UI icons](docs/ui-icons.md) · +[Text effects](docs/text-effects.md) · +[Onboarding](docs/onboarding.md) · +[i18n](docs/i18n.md) · +[Feature flags](docs/feature-flags.md) + +**Protocol, data and performance** — [RPC namespacing](docs/rpc-namespacing.md) · +[Protocol validation](docs/protocol-validation.md) · +[Data model](docs/data-model.md) · +[Token economy](docs/token-economy.md) · +[Terminal performance](docs/terminal-performance.md) + +**Testing** — [Testing](docs/testing.md) · +[Mobile testing](docs/mobile-testing.md) · +[Ad-hoc daemon testing](docs/ad-hoc-daemon-testing.md) · +[Browser capture harness](docs/browser-capture-harness.md) + +**Build, release and operations** — [Release](docs/release.md) · +[Fork release guide](docs/fork-release-guide.md) · +[Upstream merges](docs/upstream-merges.md) · +[Android](docs/android.md) · +[Desktop Linux](docs/desktop-linux.md) · +[Docker](docs/docker.md) + +**Reference** — [References and sources](docs/references.md) · +[OpenCode event baseline](docs/opencode-global-event-baseline.md) + +### Repository documents + +[CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md) · [CHANGELOG.md](CHANGELOG.md) · +[LICENSE](LICENSE) · [NOTICE](NOTICE) + +## Community + +- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go (built for the upstream Paseo project) +- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension (built for the upstream Paseo project) + +--- + +

+ + + + + Star history chart for Draek2077/otto-code + + +

-Other scripts: - -| Script | Description | -|--------|-------------| -| `pnpm run dev:demo` | Start with demo/mock data | -| `pnpm run dev:relay` | Run the event relay server standalone | -| `pnpm run dev:extension` | Watch-build the extension | -| `pnpm run build:all` | Production build (webview + extension) | -| `pnpm run build:web` | Build the Next.js web app | -| `pnpm run build:extension` | Build the extension | -| `pnpm run build:webview` | Build the webview assets | - -## Star History +## License -[![Star History Chart](https://api.star-history.com/chart?repos=patoles/agent-flow&type=date&legend=bottom-right)](https://www.star-history.com/?repos=patoles%2Fagent-flow&type=date&legend=bottom-right) +Otto is licensed under **AGPL-3.0**, the same license as the upstream project it is +based on. See [LICENSE](LICENSE) and [NOTICE](NOTICE). +## Credits & attribution -## Author +Otto is mostly other people's good work, assembled. Two projects carry it, and I'd rather +name them properly than bury them in a footer. -Created by [Simon Patole](https://github.com/patoles), for [CraftMyGame](https://craftmygame.com). +### Paseo — by Mohamed Boudra -## Privacy & Telemetry +Otto is a modified fork of **[Paseo](https://github.com/getpaseo)**, created by +**Mohamed Boudra** and contributors, © 2025–present. Paseo is licensed under +AGPL-3.0; Otto continues under the same license as required by its copyleft terms. -Agent Flow ships **opt-out** anonymous usage telemetry, enabled by default only -in the published `npx agent-flow-app` binary. `pnpm run dev` and the VS Code -extension emit nothing. Only aggregate events are sent — session count, -duration, event count, OS/arch, Agent Flow version, distinct model IDs -observed, which runtimes were watched, and error class names. Prompts, file -paths, tool calls, user info, and environment variables are never sent. +Mo got the hard parts right before I ever showed up: agent process lifecycle, a clean +WebSocket protocol, genuinely cross-platform clients, an end-to-end encrypted relay. +That's why the work here can be features instead of plumbing. Otto keeps the full +foundation intact with upstream history preserved. +→ [Sponsor Mo](https://github.com/sponsors/boudra) -- **Turn off:** `export AGENT_FLOW_TELEMETRY=false` or `export DO_NOT_TRACK=1` - (disabled installs write zero state to disk — no `~/.agent-flow/` directory) -- **Inspect the payload:** `cat ~/.agent-flow/telemetry/events.jsonl` -- **Full schema + exact fields:** see the v0.8.1 entry in - [extension/CHANGELOG.md](extension/CHANGELOG.md) or the `serialize()` function - in [scripts/telemetry.ts](scripts/telemetry.ts) -- **Reset your anonymous identity:** delete `~/.agent-flow/installation-id` — - a fresh random UUIDv4 will be generated on next run +### Agent Flow — by Simon Patole +Otto's **Visualizer** — the live node-graph of agents, subagents, tool calls, and timeline +that makes an autonomous run something you can watch instead of guess at — is the render +layer of **[Agent Flow](https://github.com/patoles/agent-flow)** (Apache-2.0) by +**[Simon Patole](https://github.com/patoles)**, vendored as a git subtree. -## License +It's beautiful work, and it fit because Simon kept rendering separate from event collection +behind a small documented bridge protocol. That one decision let Otto drive the same graph +from its own provider-neutral event stream, so it lights up for Claude, Codex, OpenCode, or +a local model alike — not just the runtime the original ingests. Adapting it has been the +most enjoyable part of building Otto. Carried patches and the Apache-2.0 state-changes +notice live in `vendor/agent-flow/OTTO-PATCHES.md`; upstream PRs are preferred over carrying +them. Agent Flow's name and logos are its own and Otto never ships them as its branding — +the feature is called "Visualizer" for exactly that reason. +→ [Star Agent Flow](https://github.com/patoles/agent-flow) -Apache 2.0 — see [LICENSE](LICENSE) for details. +### Notices -The name "Agent Flow" and associated logos are trademarks of Simon Patole. See [TRADEMARK.md](TRADEMARK.md) for usage guidelines. +The original copyright notice is preserved verbatim in [LICENSE](LICENSE). A summary +of what Otto changes relative to Paseo, along with full attribution, lives in +[NOTICE](NOTICE). Otto is an independent project and is not endorsed by or affiliated +with the Paseo project, the Agent Flow project, or their authors. Otto takes no +sponsorships of its own — support goes upstream. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..4449fc8a9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,75 @@ +# Security + +Otto follows a client-server architecture, similar to Docker. The daemon runs on your machine and manages your coding agents. Clients (the mobile app, CLI, or web interface) connect to the daemon to monitor and control those agents. + +Your code never leaves your machine. Otto is a local-first tool that connects directly to your development environment. + +## Architecture + +The Otto daemon can run anywhere you want to execute agents: your laptop, a Mac Mini, a VPS, or a Docker container. The daemon listens for connections and manages agent lifecycles. + +Clients connect to the daemon over WebSocket. There are two ways to establish this connection: + +- **Relay connection** — The daemon connects outbound to our relay server, and clients meet it there. No open ports required. +- **Direct connection** — The daemon listens on a network address and clients connect directly. + +## Relay threat model + +The relay is designed to be untrusted. All traffic between your phone and daemon is end-to-end encrypted. The relay server cannot read your messages, see your code, or modify traffic without detection. Even if the relay is compromised, your data remains protected. + +### How it works + +1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$OTTO_HOME/daemon-keypair.json` with mode `0600` +2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.otto-code.me/#offer=...`). Fragments are not sent to the web server, so `app.otto-code.me` never sees the key. +3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. +4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame. + +The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. + +### Why the relay can't attack you + +The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot: + +- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone +- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon. +- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake +- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected +- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. + +### Trust model + +The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. + +## Local daemon trust boundary + +By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address. + +The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `OTTO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer ` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: otto.bearer.` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks. + +Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. + +If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case. + +**WSL exception:** Windows' localhost forwarding into WSL2 only proxies to services bound beyond the WSL VM's own loopback interface, so a daemon bound to `127.0.0.1` inside WSL is unreachable from a Windows-side client (the mobile/desktop app, browser) without manual configuration. When the daemon detects it's running under WSL and no `listen` address was explicitly configured, it defaults to `0.0.0.0` instead of `127.0.0.1` so the Windows host can reach it automatically. WSL2's NAT still keeps the daemon unreachable from other machines on the LAN, but it is now reachable from anything running on the same physical Windows host. If no password is configured, the daemon logs a one-time warning recommending `OTTO_PASSWORD`; set it if you don't fully trust other processes on that host. + +In Docker, the official image runs the daemon and agents as the non-root +`otto` user by default. Mounted workspaces and credentials are still fully +available to anything the agents run inside the container. + +For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted. + +Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation. + +## DNS rebinding protection + +CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding). + +Otto validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `OTTO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`. + +## Agent authentication + +Otto wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Otto never stores or transmits provider API keys. Agents run in your user context with your existing credentials. + +## Reporting vulnerabilities + +If you discover a security vulnerability, please report it privately by emailing dreakz@gmail.com. Do not open a public issue. diff --git a/archdocs/README.md b/archdocs/README.md new file mode 100644 index 000000000..4b2de05c9 --- /dev/null +++ b/archdocs/README.md @@ -0,0 +1,43 @@ +# archdocs — Otto architecture documentation site + +Docs-as-code architecture documentation: AsciiDoc pages with embedded Mermaid +diagrams, built to a self-contained static site (no network, no external services — +mermaid renders client-side from a vendored bundle). + +## Use + +```bash +npm run archdocs:build # pages/*.adoc -> dist/*.html +npm run archdocs:serve # build + serve on http://127.0.0.1:4400 +``` + +Or start it as a preview server (entry `archdocs` in `.claude/launch.json`). + +## Layout + +- `pages/` — the documentation set, numerically ordered; `00-index.adoc` is the + master table of contents. +- `templates/` — skeletons for new documents (system overview, process flow, ERD, + technical design). Copy one; keep every section. Consistent structure is what + makes LLM-authored docs reviewable by humans. +- `build.mjs` / `serve.mjs` / `theme.css` — the toolchain. `[mermaid]` listing + blocks in AsciiDoc pass through as `
` and render in the
+  browser (light/dark aware).
+- `dist/` — build output, not committed.
+
+## Authoring rules
+
+1. **Node budgets are hard limits.** Flowcharts ≤ 15–20 nodes, sequences ≤ 6
+   participants, ERDs ≤ 10–12 entities. Overflow means the subject needs a child
+   page, not a bigger diagram.
+2. **Every diagram states its why.** New diagrams are proposed through
+   `pages/04-diagram-catalog.adoc` with the question they answer. No why, no diagram.
+3. **Invariants are the point.** System pages end with numbered, checkable
+   invariants and a change/audit checklist — that is what makes these docs an audit
+   instrument instead of a description.
+4. **No line numbers, no full schemas.** Reference files by path; schemas live in
+   code (`packages/protocol`). This set documents boundaries and flows, not copies
+   of the source.
+5. **`docs/` still owns subsystem gotchas.** This set is the layer above it. When
+   they disagree: code wins, then `docs/`, then archdocs — and the disagreement is a
+   bug to fix here.
diff --git a/archdocs/build.mjs b/archdocs/build.mjs
new file mode 100644
index 000000000..3e252a3db
--- /dev/null
+++ b/archdocs/build.mjs
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+// Builds the architecture docs site: archdocs/pages/*.adoc -> archdocs/dist/*.html
+// Diagrams are authored as [mermaid] listing blocks and rendered client-side by
+// mermaid.min.js (vendored from node_modules at build time — no network needed).
+import { Extensions, load } from "@asciidoctor/core";
+import { copyFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { basename, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const pagesDir = join(here, "pages");
+const distDir = join(here, "dist");
+const require = createRequire(import.meta.url);
+
+const escapeHtml = (s) =>
+  s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+
+// [mermaid] blocks pass through as 
 for client-side rendering.
+const registry = Extensions.create();
+registry.block(function () {
+  this.named("mermaid");
+  this.onContext(["listing", "open"]);
+  this.parseContentAs("raw");
+  this.process((parent, reader) => {
+    const source = reader.getLines().join("\n");
+    return this.createPassBlock(parent, `
${escapeHtml(source)}
`, {}); + }); +}); + +// maxRetries/retryDelay are for Windows: a serve process, editor or indexer holding a +// handle under dist/ makes rmSync throw EPERM, which failed the build intermittently +// whenever `archdocs:serve` was running against the same tree. +rmSync(distDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +mkdirSync(distDir, { recursive: true }); +copyFileSync(require.resolve("mermaid/dist/mermaid.min.js"), join(distDir, "mermaid.min.js")); +copyFileSync(join(here, "theme.css"), join(distDir, "theme.css")); + +const pageFiles = readdirSync(pagesDir) + .filter((f) => f.endsWith(".adoc")) + .sort(); + +const pages = []; +for (const file of pageFiles) { + const source = readFileSync(join(pagesDir, file), "utf8"); + const doc = await load(source, { + safe: "safe", + extension_registry: registry, + attributes: { showtitle: true, icons: "font", sectlinks: "" }, + }); + pages.push({ + file, + href: `${basename(file, ".adoc")}.html`, + title: doc.getDoctitle({ use_fallback: true }), + body: await doc.convert(), + }); +} + +// Titles from getDoctitle() are already HTML-escaped by asciidoctor — do not re-escape. +const nav = (active) => + pages + .map((p) => `${p.title}`) + .join("\n "); + +for (const page of pages) { + const html = ` + + + + +${page.title} · Otto Architecture + + + +
+ +
+${page.body} +
+
+ + + + +`; + writeFileSync(join(distDir, page.href), html); +} + +writeFileSync(join(distDir, "index.html"), readFileSync(join(distDir, pages[0].href), "utf8")); + +console.log(`archdocs: built ${pages.length} pages -> ${distDir}`); diff --git a/archdocs/pages/00-index.adoc b/archdocs/pages/00-index.adoc new file mode 100644 index 000000000..c8a52f2f3 --- /dev/null +++ b/archdocs/pages/00-index.adoc @@ -0,0 +1,139 @@ += Otto Architecture Documentation +:description: Master table of contents for the Otto architecture documentation set. + +This site is the professional-grade architecture record for Otto — the systems, their +boundaries, and the diagrams that explain them. It is *docs-as-code*: every page is +AsciiDoc, every diagram is an embedded Mermaid text block, so both humans and LLMs can +review, diff, and regenerate them. Build with `npm run archdocs:build`, browse with +`npm run archdocs:serve`. + +[NOTE] +==== +*Human-consumability is a hard requirement.* Every diagram in this set carries a node +budget (15–20 nodes max). When a subject exceeds its budget, the overflow is abstracted +into a linked child page — never crammed into the same picture. A diagram nobody can +read is a failed deliverable, no matter how accurate it is. +==== + +== How this set is organized + +[cols="1,3"] +|=== +|Page |What it answers + +|link:01-system-overview.html[1. System Overview] +|What are the major systems that govern Otto, and how do they relate? The context +diagram and the eight governing systems. + +|link:02-module-map.html[2. Module Map] +|Where does code live? The frontend/backend split, package layering, directory-to-system +mapping, and the honest inventory of oversized modules. + +|link:03-configuration.html[3. Configuration Systems] +|What drives Otto from outside its code boundary? Config layers, environment variables, +per-workspace files, device-local settings, and the two flag systems. + +|link:04-diagram-catalog.html[4. Diagram Catalog] +|Which diagrams does this system need, and *why* does each one earn its place? + +|link:05-audit-architecture.html[5. Audit Architecture] +|How Otto measures and proves its own cost: the token/cache measurement model, the +provider-agnostic accounting contract, sub-agent roll-up and de-inflation, the +ledger, and the transparency invariants. + +|link:06-engineering-guide.html[6. Engineering & Audit Guide] +|How to use this documentation to implement and audit — with deep dives on the four +hardest problem areas: chat sync, usage accounting, UI performance, IDE tooling. +|=== + +Document templates for new pages live in `archdocs/templates/` — use them so every +future document (LLM- or human-authored) has the same reviewable structure. + +== Master table of contents + +The full breadth of Otto's systems, in hierarchy. Bold entries have dedicated coverage +in this set today; the rest are the growth points for future detail pages. + +=== 1. Product & runtime topology +* **1.1 System context** — clients, daemon, providers, relay, forges (§ Overview) +* **1.2 Deployment models** — local daemon, managed desktop, remote via relay +* 1.3 Security posture — auth & pairing, E2EE relay, threat model (`SECURITY.md`) + +=== 2. Shared contract (the spine) +* **2.1 Wire protocol** — `packages/protocol`: message schemas, binary frames + (terminal, file transfer), RPC namespacing, compatibility rules +* **2.2 Generated validation** — zod-aot ahead-of-time validators +* **2.3 Client SDK** — `packages/client`: DaemonClient, transport strategies + (WebSocket, relay-E2EE) + +=== 3. Daemon (backend) +* **3.1 Bootstrap & composition root** — service construction order, config load +* **3.2 Transport & session layer** — WebSocket server, per-client `Session` + dispatch (the god class + its in-progress decomposition), relay transport +* **3.3 Agent runtime** — AgentManager lifecycle state machine, timeline + persistence (epoch/seq), provider registry & snapshots, prompt/context composition +* **3.4 Provider adapters** — two loop-ownership models; Claude, Codex, OpenCode, + Pi, ACP family, OpenAI-compatible +* **3.5 Otto tool catalog & MCP** — transport-neutral tools, native injection vs + MCP fallback, permission policy +* **3.6 Workspace & git domain** — project/workspace/worktree registries, + checkout/diff projection, provider-neutral git hosting (GitHub, Bitbucket) +* **3.7 IDE services** — terminal workers, file/editor RPCs, preview dev-servers, + browser-tools broker, artifacts, service proxy +* **3.8 Orchestration & automation** — personalities, teams, runs, schedules, + loops, chat rooms, push notifications +* **3.9 Accounting & observability** — activity stats, usage ledger, subagent + usage roll-up, personality stats, daemon log (§ Audit Architecture) +* 3.10 Speech & voice — STT/TTS managers, dictation, voice mode + +=== 4. Clients (frontend) +* **4.1 App shell & routing** — Expo Router tree, startup restore, host-scoped routes +* **4.2 Host runtime** — HostRuntimeController, connection lifecycle, transports +* **4.3 Session & timeline sync engine** — SessionContext, stream reducer cursor + (accept/drop/gap), catch-up planning +* **4.4 Chat surface** — agent-stream rendering (virtualization, anchoring, turn + grouping), message bubble, markdown streaming, composer pipeline +* **4.5 IDE surfaces** — editor (CM6), terminal, git/diff pane, file explorer, + browser pane, artifacts, visualizer +* **4.6 State architecture** — Zustand stores, React Query as push-fed replica, + push-router +* **4.7 Platform layer** — web/native/electron gates, Metro file-extension splits +* 4.8 Settings, feature gates, i18n, theming + +=== 5. Audit & cost transparency +* **5.1 Measurement model** — the four token classes (in, out, cache read, cache + write), model-tagged splits, derived cost (§ Audit Architecture) +* **5.2 Provider-agnostic accounting** — the neutral core, the three-step adapter + contract, the pricing invariant +* **5.3 Sub-agent & orchestration roll-up** — attended vs observed sub-agents, + parent-residual de-inflation, workflow transcript accounting +* **5.4 The sinks** — activity counters (day buckets, rollups) and the itemized + usage ledger; reset atomicity; live-update pings +* **5.5 Transparency guarantees** — the ten audit invariants, local-only storage + +=== 6. Platform & infrastructure packages +* 6.1 Relay — E2EE Cloudflare Worker +* 6.2 Desktop shell — Electron main, embedded daemon supervision, GPU fallback +* 6.3 Supporting libraries — highlight, visualizer bundle, expo-two-way-audio +* 6.4 CLI — Docker-style command surface +* 6.5 Website — marketing/docs site + +=== 7. Configuration & operations +* **7.1 Configuration boundary** — env vars → persisted config → mutable config → + clients (§ Configuration) +* **7.2 Environment variable reference** — the full `OTTO_*` index (§ Configuration) +* **7.3 Per-workspace config** — `.claude/launch.json`, custom providers, `otto.json` +* **7.4 Feature systems** — client feature gates vs daemon capability flags +* 7.5 Storage at rest — `$OTTO_HOME` layout, atomic JSON stores, no-migrations rule + +=== 8. Delivery +* 8.1 Build graph — workspace build order, generated declarations +* 8.2 Release pipeline — version sync, npm publish, desktop auto-update, EAS +* 8.3 Testing tiers — unit (vitest), E2E (Playwright), demo capture, real-provider + +== Provenance & freshness + +Generated 2026-07-19 from direct reads of the working tree (v0.6.3 + uncommitted +changes), cross-checked against `docs/`. The `docs/` directory remains the source of +truth for subsystem-level gotchas; this set is the layer above it — system boundaries, +diagrams, and audit guides. When the two disagree, code wins, then `docs/`, then this. diff --git a/archdocs/pages/01-system-overview.adoc b/archdocs/pages/01-system-overview.adoc new file mode 100644 index 000000000..b6d13e78d --- /dev/null +++ b/archdocs/pages/01-system-overview.adoc @@ -0,0 +1,142 @@ += System Overview +:description: The context diagram and the eight governing systems of Otto. + +Otto is a local-first client–server system: a Node.js *daemon* on the developer's +machine spawns and supervises AI coding agents, and *clients* (mobile, web, desktop, +CLI) observe and control them over one WebSocket protocol. Code never leaves the +machine; remote access rides an end-to-end-encrypted relay. + +== System context + +[mermaid] +---- +flowchart LR + subgraph clients [Clients] + APP["Mobile / Web app
(Expo)"] + DESK["Desktop app
(Electron)"] + CLI["CLI"] + end + RELAY["Relay
(Cloudflare Worker, E2EE,
zero-knowledge)"] + subgraph host [Developer machine] + DAEMON["Otto daemon
(Node.js)"] + PROV["Agent providers
Claude · Codex · OpenCode ·
Pi · ACP · OpenAI-compat"] + REPO[("Workspaces &
git repos")] + HOME[("$OTTO_HOME
state & config")] + end + FORGE["Git hosting
GitHub · Bitbucket"] + APP -- "WebSocket (JSON + binary frames)" --> DAEMON + DESK -- "managed subprocess + WS" --> DAEMON + CLI -- "WebSocket" --> DAEMON + APP -. "remote path" .-> RELAY -. "encrypted bytes" .-> DAEMON + DAEMON --> PROV + DAEMON --> REPO + DAEMON --> HOME + DAEMON --> FORGE +---- + +Three deployment models, one architecture: a locally started daemon (default, +`127.0.0.1:6868`), a desktop app that supervises its own embedded daemon, and a +firewalled daemon reached through the relay. The relay routes ciphertext only — it +cannot read agent traffic. + +== The eight governing systems + +Everything in the codebase serves one of these systems. They are the vocabulary the +rest of this documentation set uses. + +[cols="1,2,2"] +|=== +|System |Mission |Anchors + +|*1. Contract & Transport* +|One append-only wire protocol every client speaks: message schemas, binary terminal +frames, RPC namespacing, capability negotiation, relay E2EE. Governs compatibility — +old clients must parse new daemons and vice versa. +|`packages/protocol`, `packages/client`, daemon `websocket-server.ts` / `session.ts`, +`packages/relay` + +|*2. Agent Runtime* +|The lifecycle of an agent: create → run → interrupt → archive. Provider adapters +normalize five-plus agent CLIs/SDKs into one `AgentClient` interface; the Otto tool +catalog and permission flow give every provider the same tooling. +|daemon `agent/agent-manager.ts`, `agent/providers/`, `agent/tools/`, `mcp-server.ts` + +|*3. Timeline & Sync* +|The truth about what an agent said and did, delivered to every client exactly once. +Append-only timeline keyed `{epoch, seq}`; live streams for immediacy, authoritative +paged fetches for correctness; client-side cursor reducers dedupe, drop stale epochs, +and detect gaps. +|daemon `agent-timeline-store.ts`, app `timeline/session-stream-reducers.ts`, +`docs/timeline-sync.md` + +|*4. Workspace & Git* +|Projects, workspaces, and worktrees; checkout status/diff projection; commit, push, +and PR flows through a provider-neutral git-hosting layer. +|daemon `workspace-registry.ts`, `checkout-diff-manager.ts`, `services/git-hosting/`, +app `git/` + +|*5. IDE Services* +|The frontier-harness tooling Otto levels up to every provider: terminals (PTY +workers), file/editor RPCs, preview dev-servers, browser verification tools brokered +to real app tabs, HTML artifacts, service proxy. +|daemon `terminal/`, `server/session/files/`, `preview/`, `browser-tools/`, +`artifact/`; app `editor/`, `terminal/`, browser pane + +|*6. Orchestration & Automation* +|Work above a single agent: personalities and teams, multi-agent runs, cron schedules, +loops, agent-to-agent chat, push notifications. +|daemon `agent/agent-personalities.ts`, `orchestration/`, `schedule/`, +`loop-service.ts`, `chat/`, `push/` + +|*7. Accounting & Observability* +|An honest record of what was spent and done: every provider funnels usage through +AgentManager chokepoints into a day-bucketed counter store and an itemized ledger; +subagent usage is rolled up without double counting. Full treatment: +link:05-audit-architecture.html[Audit Architecture]. +|daemon `activity-stats/`, `agent/subagent-usage.ts`, `docs/subagent-accounting.md` + +|*8. Client Experience* +|The app itself: Expo Router shell, host runtime, Zustand + push-fed React Query +state, the virtualized chat surface, platform gating across iOS/Android/web/Electron. +|`packages/app/src` — `runtime/`, `contexts/`, `stores/`, `agent-stream/`, +`components/` +|=== + +Cross-cutting all eight: *Security* (auth, pairing, E2EE — `SECURITY.md`), +*Configuration* (link:03-configuration.html[its own page]), and *Delivery* +(build graph, release, testing). + +== The agent lifecycle (shared vocabulary) + +Every system above touches this state machine; it is defined once in +`packages/protocol/src/agent-lifecycle.ts` and enforced by AgentManager. + +[mermaid] +---- +stateDiagram-v2 + [*] --> initializing : create + initializing --> idle : session ready + idle --> running : prompt + running --> idle : turn complete + initializing --> error + running --> error + idle --> error + error --> running : retry + error --> closed + idle --> closed : close / archive + closed --> [*] +---- + +Key invariants: `AgentManager` is the single source of truth for state and broadcasts +every change; each new run starts a new timeline *epoch*; `closed` is terminal. + +== How a prompt becomes pixels (one paragraph) + +A client sends a create/prompt RPC → the `Session` layer routes it to `AgentManager` → +the provider adapter runs the turn and emits stream events → events are appended to +the `{epoch, seq}` timeline, persisted, and broadcast → each client's stream reducer +accepts/dedupes them into its store → the virtualized chat surface renders. Tool calls +detour through the permission flow (agent → daemon → client → human → back); usage +events detour into the accounting stores. Those two detours, and the sync loop +itself, are drawn in the link:06-engineering-guide.html[Engineering & Audit Guide] +and link:05-audit-architecture.html[Audit Architecture]. diff --git a/archdocs/pages/02-module-map.adoc b/archdocs/pages/02-module-map.adoc new file mode 100644 index 000000000..6b3610276 --- /dev/null +++ b/archdocs/pages/02-module-map.adoc @@ -0,0 +1,210 @@ += Module Map +:description: Package layering, the frontend/backend split, and the oversized-module inventory. + +Otto is an npm workspace monorepo of eleven packages. The split is clean at the +package boundary: *backend* is everything the daemon process runs, *frontend* is +everything a client renders, and the two only meet through the shared contract +packages — never by importing each other. + +== Package layering + +[mermaid] +---- +flowchart TD + subgraph contract [Shared contract] + PROTO["protocol
wire schemas, binary frames,
zod-aot validators"] + CLIENT["client
DaemonClient + transports"] + end + subgraph backend [Backend] + SERVER["server
the daemon"] + RELAY["relay
E2EE worker"] + CLI["cli"] + end + subgraph frontend [Frontend] + APP["app
Expo: iOS · Android · web"] + DESKTOP["desktop
Electron shell"] + VIS["visualizer
webview bundle"] + AUDIO["expo-two-way-audio"] + end + HL["highlight
shared syntax highlighting"] + WEB["website (standalone)"] + PROTO --> CLIENT + CLIENT --> SERVER + CLIENT --> APP + CLIENT --> CLI + RELAY --> CLIENT + RELAY --> SERVER + HL --> APP + HL --> SERVER + VIS --> APP + AUDIO --> APP + SERVER --> DESKTOP + CLI --> DESKTOP + APP -- "web export" --> DESKTOP +---- + +Rules the diagram encodes: + +* `protocol` is the universal dependency and depends on nothing internal. All + compatibility guarantees live here (append-only schemas, capability gating). +* `client` is the only sanctioned way for a client to talk to a daemon. The daemon + also consumes it (agent-to-daemon tooling), which is why it sits in the contract + layer, not the frontend. +* `desktop` is an assembler, not a source of features: it bundles the server, the CLI, + and the app's web export, adding only Electron-specific glue (window management, + embedded-daemon supervision, GPU fallback). +* `website` shares nothing with the product runtime by design. + +== Backend: `packages/server` (~630 files) + +The daemon decomposes into the eight governing systems as follows. + +[cols="1,2,2"] +|=== +|Area |Modules |Notes + +|Composition root +|`bootstrap.ts` +|Constructs every long-lived service in one deliberate order (storage → accounting → +registries → AgentManager → feature services). The definitive list of "what exists at +runtime". + +|Transport & session +|`websocket-server.ts`, `session.ts`, `server/session/*`, `relay-transport.ts` +|`session.ts` is the per-client RPC dispatcher — and the largest module in the repo +(see inventory below). `server/session/` holds its in-progress decomposition: files, +checkout, git-mutation, provider, voice sub-handlers. + +|Agent runtime +|`agent/agent-manager.ts`, `agent/create-agent/`, `agent/providers/`, +`agent/tools/otto-tools.ts`, `agent/mcp-server.ts` +|Providers split into two loop-ownership models: *provider-owned* loops (Claude, +Codex, OpenCode, Pi, ACP family — the daemon streams and maps events) and +*daemon-owned* loops (OpenAI-compatible — the daemon runs the tool rounds, injects +the Otto catalog, compacts mid-turn, bills per round). + +|Timeline & persistence +|`agent/agent-storage.ts`, `agent/agent-timeline-store.ts`, `timeline-append.ts`, +`timeline-projection.ts` +|Append-only rows keyed `{epoch, seq}`, file-backed under `$OTTO_HOME/agents/`. + +|Workspace & git +|`workspace-registry.ts`, `workspace-git-service.ts`, `otto-worktree-service.ts`, +`checkout-diff-manager.ts`, `services/git-hosting/`, `services/github-service.ts` +|Git hosting is provider-neutral: a resolver/router in front of GitHub and Bitbucket +implementations. + +|IDE services +|`terminal/` (PTY worker process), `server/session/files/`, `preview/`, +`browser-tools/`, `artifact/`, `service-proxy.ts` +|`browser-tools/broker.ts` is the daemon side of browser verification — it brokers +agent tool calls to a *connected client's* real browser tab; the daemon never runs a +browser itself. + +|Orchestration & automation +|`agent/agent-personalities.ts`, `agent/agent-teams.ts`, `orchestration/` (run +engine), `schedule/`, `loop-service.ts`, `chat/`, `push/` +| + +|Accounting +|`activity-stats/` (counter store + usage ledger), `agent/subagent-usage.ts`, +`agent/claude/claude-pricing.ts`, `personality-stats-store.ts` +|All writes funnel through AgentManager callbacks — the audit chokepoint. + +|Speech & voice +|`speech/`, `agent/stt-manager.ts`, `agent/tts-manager.ts`, `dictation/` +| +|=== + +== Frontend: `packages/app` (~1,700 files) + +[cols="1,2,2"] +|=== +|Area |Modules |Notes + +|Shell & routing +|`app/` (Expo Router tree), `screens/` +|Host-scoped routes: `/h/[serverId]/workspace/[workspaceId]`. Startup restore resolves +the redirect from bootstrap state + last-workspace memory. + +|Host runtime +|`runtime/host-runtime.ts`, `desktop/` +|`HostRuntimeController` owns daemon connections per host: transport selection +(local, WS, relay), reconnection, exposure via `useSyncExternalStore`. + +|Sync engine +|`contexts/session-context.tsx`, `timeline/`, `data/push-router.ts`, `data/query.ts` +|The frontend half of Timeline & Sync: reducer queue with a `{epoch, seq}` cursor, +catch-up planning, and a React Query cache that is a *push-fed replica* — global +`refetch*: false`, updated only by push-router writes and explicit invalidation. + +|Chat surface +|`agent-stream/`, `components/message.tsx`, `components/markdown/`, `composer/` +|Custom partial virtualization on web (threshold 100 items, ~50 recent mounted), +bottom-anchoring, turn grouping, 48 ms batched reducer flushes. + +|IDE surfaces +|`editor/` (CM6), `terminal/` (xterm webview), `git/` (diff pane, PR panel), +`components/browser-pane.*`, `components/artifacts/`, `visualizer/` +|Each pairs with a daemon IDE service; the pane/tab system in `stores/` + +`panels/` hosts them. + +|State +|`stores/` (21 Zustand stores), `hooks/` +|`session-store` is the domain core (agents, workspaces, live stream items); +layout/panel/draft/sidebar stores are UI-local; a handful persist to AsyncStorage. + +|Platform layer +|`constants/platform.ts`, `*.web.tsx` / `*.native.tsx` / `*.electron.tsx` splits +|~37 file-extension splits; the rule is Metro splits for divergent implementations, +inline `isWeb`/`isNative` gates for small branches. + +|Settings & features +|`hooks/use-settings/`, `features/` (feature catalog + gates), `i18n/` (10 locales) +| +|=== + +== Oversized-module inventory (the cracks) + +These modules work, but each one concentrates too many systems in one file. They are +where "vibe coding" accumulates fastest, and they are the standing refactor backlog. +Sizes are lines of code at generation time. + +[cols="2,1,3"] +|=== +|Module |LOC |Systems entangled / decomposition status + +|`server/session.ts` +|7,024 +|~186 methods dispatching nearly every inbound RPC. Decomposition *in progress*: +`server/session/` sub-handlers (files, checkout, git-mutation, provider, voice) +already exist; the plan is `projects/session-decomposition/`. + +|`agent/providers/claude/agent.ts` +|6,684 +|Provider adapter + tool mapping + subagent observation + rewind in one file. Same +shape problem in `codex-app-server-agent.ts` (6,329), `opencode-agent.ts` (3,834), +`openai-compat-agent.ts` (3,563), `acp-agent.ts` (3,403). + +|`agent/agent-manager.ts` +|5,916 +|Lifecycle + broadcast + accounting callbacks + timeline tracking. The chokepoint +role is intentional; the size is not. + +|`screens/workspace/workspace-screen.tsx` +|4,740 +|The workspace deck: panes, tabs, layout, keyboard, drag-drop. Largest frontend file. + +|`agent/tools/otto-tools.ts` +|4,426 +|The whole tool catalog in one module — each tool is a candidate for per-domain files. + +|`git/diff-pane.tsx` / `components/message.tsx` +|3,862 / 3,371 +|Diff rendering and the chat bubble renderer — both performance-critical, both +monolithic. +|=== + +The rule going forward: new capability code lands in the decomposed shape (per-domain +session handlers, per-domain tool files), and every substantial touch of a listed +module should move at least one responsibility *out*. diff --git a/archdocs/pages/03-configuration.adoc b/archdocs/pages/03-configuration.adoc new file mode 100644 index 000000000..0ed92bd52 --- /dev/null +++ b/archdocs/pages/03-configuration.adoc @@ -0,0 +1,288 @@ += Configuration Systems +:description: Everything that drives Otto from outside its code boundary. + +Configuration is layered by *who writes it* and *where it lives*. From outermost to +innermost: environment variables (operator), persisted daemon config (operator + +settings UI), per-workspace files (repo authors), and device-local client settings +(each user's device). A fifth family — build/deploy config — shapes the binaries +before they ever run. + +== Configuration flow + +[mermaid] +---- +flowchart TD + ENV["Environment
OTTO_* variables"] + CFG["$OTTO_HOME/config.json
(PersistedConfig, strict schema)"] + LOAD["loadConfig
merge + validate"] + MDC["MutableDaemonConfig
(runtime snapshot)"] + UI["Settings UI / RPC patches"] + CLIENTS["All connected clients
(daemon_config_changed push)"] + WSCFG[".claude/launch.json
(per workspace)"] + PREVIEW["Preview subsystem"] + DEV["Device-local AppSettings
(AsyncStorage)"] + APPUI["App behavior + feature gates"] + ENV --> LOAD + CFG --> LOAD + LOAD --> MDC + UI -- "deep-merge patch" --> MDC + MDC -- "hot reload" --> CLIENTS + MDC -. "persisted back" .-> CFG + WSCFG --> PREVIEW + DEV --> APPUI +---- + +The important asymmetry: daemon config is *shared truth* pushed to every client; +device settings are *private per device* and never sync to the daemon. + +== Layer 1 — Environment variables (`OTTO_*`) + +The operator's override channel and the only layer that can precede config loading +itself. Precedence: *environment > persisted config > built-in default*. The domain +groups below give orientation; the <> is the complete +per-variable index (purpose, value format, default, consumer). + +[cols="1,2"] +|=== +|Group |Representative variables + +|Identity & paths +|`OTTO_HOME` (state root, default `~/.otto`), `OTTO_WEB_UI_DIST_DIR` + +|Network +|`OTTO_LISTEN` (TCP, unix socket, or Windows named pipe), `OTTO_PASSWORD`, +`OTTO_URL` / `OTTO_DAEMON_URL` (client-side endpoints) + +|Relay +|`OTTO_RELAY_ENABLED`, `OTTO_RELAY_ENDPOINT`, `OTTO_RELAY_PUBLIC_ENDPOINT`, +`OTTO_RELAY_USE_TLS`, `OTTO_RELAY_PUBLIC_USE_TLS` + +|Service proxy +|`OTTO_SERVICE_PROXY_ENABLED`, `OTTO_SERVICE_PROXY_LISTEN` + +|Speech & voice +|`OTTO_DICTATION_*`, `OTTO_VOICE_MODE_ENABLED`, `OTTO_SPEECH_*`, +`OTTO_LOCAL_MODELS_DIR` + +|Desktop / Electron +|`OTTO_WEB_PLATFORM` (selects `.electron.*` Metro resolution), `OTTO_FORCE_GPU`, +`OTTO_ELECTRON_FLAGS`, `OTTO_DESKTOP_MANAGED` + +|Agent-process context +|`OTTO_AGENT_ID`, `OTTO_WORKSPACE_ID`, `OTTO_WORKTREE_PATH`, `OTTO_BRANCH_NAME` — +injected *into* spawned agent processes and terminal hooks, so agents can call home +(`OTTO_ACTIVITY_TOKEN`, `OTTO_TERMINAL_ACTIVITY_URL`) + +|Diagnostics +|`OTTO_DEBUG`, `OTTO_LOG_LEVEL`, `OTTO_LOG_FORMAT`, `OTTO_NODE_INSPECT` +|=== + +== Layer 2 — Daemon config (`$OTTO_HOME/config.json`) + +Two schemas, one file: + +* *PersistedConfig* (`server/persisted-config.ts`) — the strict, versioned on-disk + shape. Top-level sections: `daemon` (listen, allowed hosts, trusted proxies, relay, + auth, CORS, service proxy, terminal profiles, browser tools, agent behaviors), + `providers`, `worktrees`, `agents` (custom providers, personalities, teams, model + tier overrides, metadata generation), `features` (dictation, voice mode, web UI), + `gitHosting.providers`, `log`. +* *MutableDaemonConfig* (`packages/protocol`) — the runtime projection clients can + read and patch over RPC. Hot-reloaded: a deep-merge patch triggers a + `daemon_config_changed` push so every client converges without reconnecting. + +This file is also where *server-side capability data* lives that shapes agents at +spawn time: custom provider definitions (extending `openai-compatible` or ACP), agent +personalities and teams (snapshotted onto each agent at spawn — later edits don't +mutate running agents). + +== Layer 3 — Per-workspace files (repo-authored) + +[cols="1,2"] +|=== +|File |Role + +|`.claude/launch.json` +|Preview dev-server registry: named entries (`runtimeExecutable`, `runtimeArgs`, +`port`) the preview subsystem may spawn and supervise. The only sanctioned way agents +start dev servers. + +|`otto.json` +|Per-project Otto config (schema in `packages/protocol/src/otto-config-schema.ts`). + +|Provider-native config +|Files the *providers* read from the workspace (e.g. `CLAUDE.md`, `~/.claude/settings.json` +for model discovery). Otto deliberately does not own these — providers handle their +own auth and context. +|=== + +== Layer 4 — Device-local client settings + +* *AppSettings* — a Zustand store persisted to AsyncStorage (with validation, + migration/backfill, and corrupt-blob reset). Holds UI preferences and the + feature-gate map. +* *Host connections* — the saved list of daemons this device can reach + (direct socket, named pipe, or relay + keys), the client-side root of trust. +* *Feature gates* (client-side) — `features/feature-catalog.ts` defines a + `FeatureId` catalog with per-feature defaults; a sparse `featureEnabled` map on + AppSettings overrides them. A disabled feature stays out of the JS heap via dynamic + `import()` boundaries (Metro cannot tree-shake on runtime flags). Never synced to + the daemon. + +[IMPORTANT] +==== +There are *two* flag systems and they must not be confused. +*Client feature gates* (above) are per-device UX choices. +*Daemon capability flags* (`server_info.features.\*`) are compatibility signals — a +client detects whether the connected daemon supports a feature and either runs it or +shows "update the host". Capability detection happens once per feature, in one place; +there are no fallback code paths. +==== + +== Layer 5 — Build & deploy config + +[cols="1,2"] +|=== +|File |Shapes + +|`packages/app/eas.json` |Expo Application Services build/submit profiles (mobile) +|`packages/desktop/electron-builder.yml` |Desktop packaging, installers, auto-update +|`packages/relay/wrangler.toml`, `packages/website/wrangler.toml` |Cloudflare Worker deploys +|`packages/app/playwright*.config.ts` |E2E and demo-capture pipelines +|Root `package.json` scripts + `scripts/` |The build graph and release pipeline +|=== + +== Audit note + +`$OTTO_HOME` is the complete state boundary of a daemon: config, agent records, +timelines, accounting stores, keys, logs. Backing it up captures a host; diffing two +snapshots of it is a legitimate audit technique. All stores are schema-validated +JSON with atomic writes and *no migrations* — see `docs/data-model.md`. + +[[appendix-env]] +== Appendix — `OTTO_*` environment variable reference + +Built from an exhaustive sweep of `process.env.OTTO_*` consumers across all packages. +Conventions: *booleans* accept `1/true/yes/y/on` and `0/false/no/n/off`; paths may +use `~`; unset means "fall back to persisted config, then the stated default". + +=== Operator-facing — core daemon & network + +[cols="2,3,3,2"] +|=== +|Variable |Purpose |Value format |Default when unset + +|`OTTO_HOME` |Otto data/home directory |absolute path or `~/…` |`~/.otto` +|`OTTO_LISTEN` |Daemon bind address |`host:port`, `/path/socket`, `unix:///path/socket` +|`127.0.0.1:6868` (`0.0.0.0:port` under WSL) +|`OTTO_HOST` |CLI's target daemon |`host:port`, `tcp://…`, `unix://…`, `pipe://…` +|autodetect: configured listen → pid socket → `localhost:6868` +|`OTTO_PASSWORD` |Daemon auth password (hashed server-side); also CLI client auth +|string |unset = no password auth +|`OTTO_SERVER_ID` |Override the daemon's server id |string |generated & persisted +|`OTTO_PAIRING_QR` |Print pairing QR to stdout on start |boolean |on when stdout is a TTY +|`OTTO_PRIMARY_LAN_IP` |Override the LAN IP advertised in connection offers +|IP address |auto-detected +|`OTTO_HOSTNAMES` / `OTTO_ALLOWED_HOSTS` |Allowed request hostnames +|comma-separated list |merged with persisted config +|`OTTO_TRUSTED_PROXIES` |Trusted reverse proxies |`true`, `false`, or comma-separated list +|`loopback` +|`OTTO_CORS_ORIGINS` |Allowed CORS origins |comma-separated list |persisted / none +|`OTTO_APP_BASE_URL` |Base URL used in generated links |URL |`https://app.otto-code.me` +|`OTTO_LOG_LEVEL` |Log verbosity |`trace`/`debug`/`info`/`warn`/`error` |persisted / `info` +|`OTTO_LOG_FORMAT` |Log output format |`pretty` / `json` |persisted +|`OTTO_NODE_INSPECT` |Inspector flag for the supervised daemon |Node inspector arg |`--inspect` +|`OTTO_LOG_ROTATE_SIZE` / `OTTO_LOG_ROTATE_COUNT` |Supervisor log rotation +|size string / positive integer |supervisor defaults +|=== + +=== Operator-facing — relay, proxy & web UI + +[cols="2,3,3,2"] +|=== +|Variable |Purpose |Value format |Default when unset + +|`OTTO_RELAY_ENABLED` |Enable the relay transport |boolean |`true` +|`OTTO_RELAY_ENDPOINT` |Relay the daemon dials |`host:port` |`relay.otto-code.me:443` +|`OTTO_RELAY_PUBLIC_ENDPOINT` |Relay endpoint advertised to clients |`host:port` +|= `OTTO_RELAY_ENDPOINT` +|`OTTO_RELAY_USE_TLS` |TLS when dialing the relay |boolean +|`true` for the default endpoint, else `false` +|`OTTO_RELAY_PUBLIC_USE_TLS` |TLS flag advertised to clients |boolean |= resolved `useTls` +|`OTTO_RELAY_UPSTREAM` |Cloudflare-worker cutover: forward all relay traffic upstream +|URL |unset = normal routing +|`OTTO_SERVICE_PROXY_ENABLED` |Enable optional service-proxy layers |boolean |enabled +|`OTTO_SERVICE_PROXY_PUBLIC_BASE_URL` |Public base URL for proxied services |URL |unset +|`OTTO_SERVICE_PROXY_LISTEN` |Standalone service-proxy listen address |`host:port` |unset +|`OTTO_WEB_UI_ENABLED` |Serve the bundled web client from the daemon |boolean |`false` +|`OTTO_WEB_UI_DIST_DIR` |Web-UI dist directory |path (absolute, or relative to `$OTTO_HOME`) +|bundled dist +|=== + +=== Operator-facing — speech, desktop & tuning + +[cols="2,3,3,2"] +|=== +|Variable |Purpose |Value format |Default when unset + +|`OTTO_VOICE_MODE_ENABLED` / `OTTO_DICTATION_ENABLED` |Enable voice mode / dictation +|boolean |persisted / `true` +|`OTTO_DICTATION_STT_PROVIDER`, `OTTO_VOICE_STT_PROVIDER`, `OTTO_VOICE_TTS_PROVIDER`, +`OTTO_VOICE_TURN_DETECTION_PROVIDER` |Select speech providers |speech provider id +(e.g. `local`, `openai`) |`local` +|`OTTO_VOICE_LLM_PROVIDER` |Voice-mode LLM provider |agent provider id |unset +|`OTTO_LOCAL_MODELS_DIR` |Local speech-model directory |path +|`$OTTO_HOME/models/local-speech` +|`OTTO_DICTATION_LOCAL_STT_MODEL`, `OTTO_VOICE_LOCAL_STT_MODEL`, +`OTTO_VOICE_LOCAL_TTS_MODEL` |Local model selection |model id |built-in defaults +|`OTTO_VOICE_LOCAL_TTS_SPEAKER_ID` / `OTTO_VOICE_LOCAL_TTS_SPEED` |Local TTS voice tuning +|integer / number |persisted / unset +|`OTTO_DICTATION_LANGUAGE` / `OTTO_VOICE_LANGUAGE` |STT language |language code (`en`, …) +|default STT language; voice falls back to dictation's +|`OTTO_DICTATION_TRANSCRIPTION_PROMPT` |STT transcription prompt |string |built-in +|`OTTO_DICTATION_SILENCE_PEAK_THRESHOLD` |Silence detection threshold |integer |`300` +|`OTTO_DICTATION_AUTO_COMMIT_SECONDS` / `OTTO_STT_BATCH_COMMIT_EVERY_SECONDS` +|Dictation/STT commit cadence |seconds (number) |built-in +|`OTTO_DICTATION_DEBUG` |Dictation debug logging + recordings |boolean |off +|`OTTO_DEBUG` |Desktop debug mode |`1` |off +|`OTTO_FORCE_GPU` |Skip the desktop GPU-fallback path |`1` |off +|`OTTO_ELECTRON_FLAGS` |Extra Chromium switches |space-separated flags |none +|`OTTO_ELECTRON_USER_DATA_DIR` |Force Electron userData dir |path |auto (per-worktree in dev) +|`OTTO_DISABLE_SINGLE_INSTANCE_LOCK` |Allow multiple desktop instances |`1` |off +|`OTTO_WEB_PLATFORM` |Web build target; `electron` selects `.electron.*` Metro files +|`electron` or unset |plain web +|`OTTO_GIT_CONCURRENCY` |Git command concurrency |integer |`8` +|`OTTO_ARTIFACT_TIMEOUT_MS` |Artifact generation timeout |integer ms |built-in +|`OTTO_LINUX_WATCH_READDIR_CONCURRENCY` |Linux watcher readdir concurrency |integer |`16` +|=== + +=== Injected by Otto into child processes + +Set by the daemon (never by operators) so agents, terminals, and workspace scripts +can identify themselves and call home. + +[cols="2,3,2"] +|=== +|Variable |Purpose |Set by + +|`OTTO_AGENT_ID` |The agent's own id, in its process env |agent manager +|`OTTO_WORKSPACE_ID` |Workspace id for workspace terminals |terminal subsystem +|`OTTO_TERMINAL_ID` |Terminal id in terminal child env |terminal manager +|`OTTO_ACTIVITY_TOKEN` / `OTTO_TERMINAL_ACTIVITY_URL` |Auth token + endpoint that +shell hooks POST activity to |terminal manager +|`OTTO_HOOK_CLI` |Path to the `otto` CLI used by shell hooks |terminal subsystem +|`OTTO_WORKTREE_PATH` / `OTTO_BRANCH_NAME` / `OTTO_WORKTREE_PORT` |Worktree context + +per-worktree service port for setup/service scripts |worktree service +|`OTTO_PORT` / `OTTO_URL` |A workspace service's own port and proxied URL |service env builder +|`OTTO_SERVICE__PORT` / `OTTO_SERVICE__URL` |Peer service ports/URLs +(name = uppercased script name) |service env builder +|`OTTO_DESKTOP_MANAGED` / `OTTO_SUPERVISED` / `OTTO_NODE_ENV` |Process-role markers; +stripped from env passed to external children |desktop / supervisor +|`OTTO_DESKTOP_CLI` |Marks a CLI invocation as desktop-launched |desktop +|=== + +Test-, benchmark-, and capture-harness variables (`OTTO_*_E2E`, `OTTO_BENCHMARK_*`, +`OTTO_PROFILE_*`, `OTTO_CAPTURE_HARNESS_*`, `OTTO_CLI_TEST_*`, `OTTO_MAESTRO_*`, +`OTTO_TEST_*`, `OTTO_ENABLE_MOCK_SLOW`, `OTTO_LOCAL_SPEECH_AUTO_DOWNLOAD`, …) are +deliberately excluded from this reference: they configure test harnesses, not the +product. Find them where the harness lives. diff --git a/archdocs/pages/04-diagram-catalog.adoc b/archdocs/pages/04-diagram-catalog.adoc new file mode 100644 index 000000000..d7b1d2708 --- /dev/null +++ b/archdocs/pages/04-diagram-catalog.adoc @@ -0,0 +1,230 @@ += Diagram Catalog +:description: The diagrams this system needs, and why each one earns its place. + +A diagram earns its place by answering questions that prose answers badly — flows, +boundaries, and state. This catalog is the curated set for Otto: each entry states +*why it exists*, the questions it answers, and its node budget. Anything not on this +list should default to prose or a table; an unmotivated diagram is decoration. + +*Legend* — Status: ✅ drawn in this set · 📋 proposed (next authoring wave). +Every diagram is Mermaid-in-AsciiDoc, so all are text-reviewable and LLM-maintainable. + +== Foundation (read first) + +[cols="1,1,2,1"] +|=== +|Diagram |Type / budget |Why it exists |Status + +|D1 · System context +|flowchart · ≤14 +|*The trust and deployment boundary.* Onboarding starts here; every security or +remote-access conversation needs the same picture of what talks to what, and which +links are encrypted. Without it, "local-first" stays a slogan instead of a checkable +property. +|✅ link:01-system-overview.html[Overview] + +|D2 · Package layering +|flowchart · ≤16 +|*The dependency law.* Build order, typecheck failures across workspaces, and "where +does this code belong" all resolve against this one picture. It is the diagram that +keeps frontend and backend from bleeding into each other. +|✅ link:02-module-map.html[Module Map] + +|D3 · Agent lifecycle +|stateDiagram · ≤8 states +|*The shared vocabulary.* Nearly every bug report ("stuck running", "archived while +idle") is a claim about this state machine. A drawn machine makes illegal transitions +visibly illegal — the reference for reviewing any lifecycle-touching change. +|✅ link:01-system-overview.html[Overview] + +|D4 · Configuration flow +|flowchart · ≤12 +|*The outside-in boundary.* Support and ops questions ("why is the daemon listening +there", "why does my device differ") are resolved by walking the layers in order. +It also enforces the shared-truth vs device-local asymmetry. +|✅ link:03-configuration.html[Configuration] +|=== + +== Correctness-critical flows + +[cols="1,1,2,1"] +|=== +|Diagram |Type / budget |Why it exists |Status + +|D5 · Chat sync loop +|sequence · ≤6 participants +|*The hardest recurring bug source.* Duplicated, missing, or out-of-order chat rows +are all failures of one loop: append → broadcast → cursor decision → catch-up. The +diagram turns "chat is weird" into a checkable path with four named outcomes +(accept / drop-stale / drop-epoch / gap). +|✅ link:06-engineering-guide.html[Guide §1] + +|D6 · Timeline & state ERD +|erDiagram · ≤10 entities +|*The persistence contract.* Epochs, sequence rows, agent records, snapshots — the +relations that make resume, rewind, and dedup possible. Any storage or migration +discussion without this picture degenerates into file-path archaeology. +|✅ link:06-engineering-guide.html[Guide §1] + +|D7 · Usage accounting flow +|flowchart · ≤12 +|*Auditability of money.* Token/cost numbers are only trustworthy if every path into +the stores is known and every store is enumerable. This diagram *is* the audit: a +usage number that didn't travel one of these edges is a bug (the no-inflation +mandate made visual). +|✅ link:05-audit-architecture.html[Audit Architecture] + +|D15 · Sub-agent settle & de-inflation +|sequence · ≤4 participants +|*The double-counting defense.* Observed sub-agents settle asynchronously while the +parent may report whole-tree costs — the one-row-per-sub-agent rule and the +parent-residual subtraction only stay correct if their ordering is explicit. This is +the diagram a provider author reads before wiring accounting. +|✅ link:05-audit-architecture.html[Audit Architecture] + +|D8 · Permission flow +|sequence · ≤6 participants +|*The safety-critical path.* Approve/deny crosses four trust domains (agent → daemon +→ client → human). Deny-by-default, unattended coercion, and responder logic can only +be reviewed against the drawn round trip — this is the diagram security review reads. +|📋 + +|D9 · Provider adapter contract +|flowchart (two lanes) · ≤14 +|*The core mission, made checkable.* "Every provider gets every capability" requires +knowing which side owns the tool loop. Drawing the provider-owned vs daemon-owned +lanes shows exactly which seams a new capability must plug into — the checklist +diagram for `docs/providers.md`. +|📋 +|=== + +== Experience-critical structure + +[cols="1,1,2,1"] +|=== +|Diagram |Type / budget |Why it exists |Status + +|D10 · Frontend state topology +|flowchart · ≤12 +|*UI correctness and performance share one map.* Stale panes, double renders, and +jitter are all questions of which store owns a fact and who is allowed to write it +(push-router vs reducer queue vs invalidation). This is the review diagram for any +new store or query. +|✅ link:06-engineering-guide.html[Guide §3] + +|D11 · Chat render pipeline +|flowchart · ≤10 +|*The 60 fps budget.* From reducer flush to virtualized mount to markdown paint — +each stage has a budget (48 ms batching, 100-item threshold, memo boundaries). +Performance audits walk this pipeline stage by stage instead of profiling blind. +|📋 + +|D12 · IDE service topology +|flowchart · ≤14 +|*The extension surface.* Preview, browser broker, terminal workers, file RPCs, +artifacts — five services with the same shape (agent tool → daemon service → client +surface). New IDE capabilities copy this shape; audits check its guardrails +(tab binding, tool-description contracts, edit gates). +|✅ link:06-engineering-guide.html[Guide §4] + +|D13 · Workspace/git domain ERD +|erDiagram · ≤10 entities +|*The most-shared state.* Projects, workspaces, worktrees, checkouts, and the +directory-vs-workspace keying rule (same `cwd`, shared git state, private drafts). +Violating that keying is a recurring bug class; the ERD makes the ownership rule +reviewable. +|📋 +|=== + +== Delivery + +[cols="1,1,2,1"] +|=== +|Diagram |Type / budget |Why it exists |Status + +|D14 · Release pipeline +|flowchart · ≤12 +|*The irreversible path.* Version sync → publish (2FA pause) → tag push → desktop +auto-update rollout → EAS. Drawn because mistakes here are public and hard to +retract; the release skill follows this map. +|📋 +|=== + +== Orchestration + +Two engines share one `Run` projection, so each needs its own picture; conflating them is +exactly the confusion these diagrams exist to prevent. + +[cols="1,1,2,1"] +|=== +|Diagram |Type / budget |Why it exists |Status + +|D16 · Run lifecycle +|stateDiagram · ≤8 states +|*Where a run can legally be.* Every "why is it stuck" question about a phase run is a claim +about this machine — above all that `paused` is a real resting state a gate puts it in, and +that a rejected gate cancels rather than fails. +|✅ link:13-orchestration-runs.html[Phase Runs] + +|D17 · Fan-out, judge, keep-best +|flowchart · ≤16 +|*The signature shape, and its exit conditions.* Spawn N → judge each → top up until the bar +is met or a cap trips. Drawn because the two ways it can end without passing (cap tripped vs +no passer at all) are easy to conflate in prose and mean different things. +|✅ link:13-orchestration-runs.html[Phase Runs] + +|D18 · Node execution lifecycle +|flowchart · ≤20 +|*The order of the gates.* Await upstream → cancel check → failure check → edge conditions → +dispatch → timeout → harvest → loop. Nearly every graph-engine question is "at which of these +did it stop, and why is that a skip and not an error". +|✅ link:14-orchestration-graphs.html[Graph Execution] + +|D19 · Prompt assembly +|flowchart · ≤12 +|*What the model actually receives.* Template-or-inline base, declared inputs, upstream +fields then prose, the output instruction, iteration context. Drawn because token cost and +"why did the node not know X" both resolve against this one picture. +|✅ link:14-orchestration-graphs.html[Graph Execution] + +|D20 · Structured output & self-correction +|sequence · ≤4 participants +|*Why structured output is provider-neutral, and where validation happens.* The tool is +registered per agent by the daemon catalog, validated in the handler, corrected in-session, +and harvested after settle with a prose fallback. The seam is invisible in prose. +|✅ link:14-orchestration-graphs.html[Graph Execution] + +|D21 · Orchestration ERD +|erDiagram · ≤10 entities +|*What owns what.* The run projection and the authored template are separate trees that meet +only at start time; the diagram makes that boundary visible and stops the two being conflated +when either schema grows. +|✅ link:12-orchestration-data-model.html[Data Model] + +|D22 · Gate pause & resume +|flowchart · ≤10 +|*The durability gap, drawn.* Where pause state must be persisted, where the +decision-arrives-first race sits, and the boot path that must rehydrate rather than fail. Drawn +because the restart edge is invisible in prose and is the system's largest open risk. +|✅ link:15-orchestration-durability.html[Durability] + +|D23 · Node anatomy — the mini-orchestrator +|flowchart (nested) · ≤10 +|*What a node actually is.* The nested envelope — retry ⊃ loop ⊃ worker + judge — with only +the outermost result crossing the node boundary. The design template for every future node +kind: a kind is a different envelope, not a different scheduler. +|✅ link:14-orchestration-graphs.html[Graph Execution] +|=== + +== Deliberately absent + +Equally important is what this catalog refuses to include: + +* *A full class diagram of the daemon or app.* Thousands of nodes; zero decisions + improved. The module map tables carry that weight at human scale. +* *Per-RPC message diagrams.* The protocol package and its generated validators are + the source of truth; a diagram would be a stale copy of a list. +* *Screen-by-screen UI flow maps.* Expo Router's file tree already is that diagram. + +When a proposed diagram busts its node budget during authoring, the subject is too +big — split the page, don't grow the picture. diff --git a/archdocs/pages/05-audit-architecture.adoc b/archdocs/pages/05-audit-architecture.adoc new file mode 100644 index 000000000..d77e345de --- /dev/null +++ b/archdocs/pages/05-audit-architecture.adoc @@ -0,0 +1,214 @@ += Audit Architecture +:description: How Otto measures, records, and proves the cost of using it — provider-agnostic token accounting, the ledger, and sub-agent roll-up. + +Otto's accounting exists to make one promise checkable: *the user can always see what +Otto cost them, and every number shown can be traced to a real measurement.* It is +accounting, not a dashboard — no roll-ups that invent precision, no estimates dressed +as facts, and an *honest blank* wherever a number can't be had for real. It is also +deliberately not telemetry: everything below stays in `$OTTO_HOME`, is never reported +anywhere, and exists for the user's own audit. + +*Read first:* `docs/subagent-accounting.md` (the adapter guide), +`docs/activity-stats.md` (the counter store and RPCs). + +== Mission & boundary + +* *Inside:* token measurement per turn and per sub-agent, cost derivation, the + itemized ledger, daemon-wide activity counters, per-personality counts, and the + RPCs/UI that surface them. +* *Outside:* provider billing truth (each provider's API is the authority on what was + charged; Otto records what providers report and prices only where it genuinely + knows the price), quota/plan limits (the quota-fetcher service reads them, it does + not account for them), and any network reporting (none exists, by design). + +== The unit of measurement + +Every measurement is a *usage split*, not a single number: + +[cols="1,3"] +|=== +|Class |Meaning + +|`inputTokens` |Fresh prompt tokens sent to the model ("in") +|`outputTokens` |Tokens the model generated ("out") +|`cacheReadInputTokens` |Prompt tokens served from the provider's prompt cache — the cheap class +|`cacheCreationInputTokens` |Prompt tokens written into the cache this turn — the investment class +|=== + +The split travels with the *model id* that produced it. Providers without a prompt +cache report the two cache classes as `0` — which reads as "all fresh", the honest +statement there. *Cost is always derived* — tokens × the price table of the code that +knows the provider — never accumulated independently, so tokens and cost cannot drift +apart. Cost arithmetic is done in micro-USD integers to avoid float drift. + +== The measurement flow + +[mermaid] +---- +flowchart LR + subgraph prov [Provider layer — the only provider-specific code] + PT["Parent-turn usage
(split + model)"] + SUB["Sub-agent frames
(live sidechain or
on-disk transcript)"] + PRICE["Provider pricing
(own price table only)"] + end + ACC["SubagentUsageAccumulator
(dedup by message id)"] + AM["AgentManager — neutral sink
turn completion · usage events"] + DEINF["Parent-residual de-inflation
tree cost − Σ sub-agent costs"] + ASS[("ActivityStatsStore
12 counters, day buckets")] + ULS[("UsageLogStore
itemized ledger rows")] + PSS[("PersonalityStatsStore")] + RPC["stats.activity.get / .reset
+ activity_stats_changed"] + UI["Metrics tiles · Log tab"] + PT --> AM + SUB --> ACC + PRICE -.-> ACC + ACC --> AM + AM --> DEINF --> ULS + AM --> ASS + AM --> PSS + ASS --> RPC + ULS --> RPC + RPC --> UI +---- + +Two ingestion paths feed the one neutral sink: + +* *Turn path* — when a provider turn completes, its accumulated usage split lands in + AgentManager, which increments the token counters and books the ledger row for + that chat. Providers where the *daemon owns the tool loop* (the OpenAI-compatible + family) are billed per round inside that loop — same sink, finer grain. +* *Activity path* — timeline items increment the behavioral counters (messages, + thoughts, tool calls) as they are recorded; lifecycle services increment theirs at + their own chokepoints (agents created, sub-agents invoked, background tasks, runs + orchestrated, schedules executed, artifacts created). Twelve counters total, each + an individually optional additive leaf so the set can grow without migrations. + +== Provider-agnostic by construction + +The core rule: *no provider's field names, model ids, or prices appear downstream of +the provider layer.* The neutral core (`agent/subagent-usage.ts`, the AgentManager +sink, the wire fields, the Log grouping) is shared; a provider contributes exactly +two things — its own usage parsing and, only if it truly knows prices, its own +pricing. Claude is the shipped reference implementation. + +The three-step adapter contract for a provider with observed sub-agents: + +1. *Parse* its wire usage into the neutral split (missing classes are `0`). +2. *Accumulate* per sub-agent across frames — the accumulator dedups by message id, + keeps the final complete frame, and remembers the model. +3. *Emit* the split + model on the observed-subagent update — and set a cost *only* + if this provider can genuinely price it, priced on the sub-agent's own model. + +Everything after that — the ledger row, chat attribution, de-inflation, grouping — +is neutral code the provider never touches. + +[IMPORTANT] +==== +*The pricing invariant.* Never dispatch pricing by model id from neutral code. An +OpenAI-compatible gateway can legitimately serve a `claude-\*` model id at entirely +different prices; keying a price table off the id alone would misprice it. Pricing is +invoked only from code that knows it is genuinely that provider. Price-table +verification (Claude re-prices against the SDK's own per-model cost and logs drift) +is diagnostic only — the residual keeps the books balanced regardless. +==== + +== Sub-agents & orchestration + +Two kinds of sub-agent, two accounting treatments: + +* *Attended* sub-agents — real Otto agents spawned via the native `create_agent` + tools (how orchestrating personalities and teams delegate). They get their own + turn completions through the normal path; no special accounting exists or is + needed. Runs started by the orchestration engine additionally bump + `runsOrchestrated`. +* *Observed* sub-agents — provider-managed workers with no Otto agent of their own + (Claude `Task` fan-out, workflow-internal agents). These ride the observed-subagent + event stream and the contract above. Claude's two worked paths: live sidechain + frames (tagged with the parent tool-use id) feed the accumulator directly; + workflow internals have no live identity, so a transcript watcher tails each + internal agent's on-disk transcript through the same accumulator. + +[mermaid] +---- +sequenceDiagram + participant AD as Provider adapter + participant AM as AgentManager (neutral sink) + participant UL as UsageLogStore + AD->>AD: accumulate frames per sub-agent
(dedup by message id) + AD->>AM: observed_subagent_updated {usage, model, cost?} + AD->>AM: terminal status (idle / error / closed) + AM->>UL: exactly one ledger row
kind "subagent", attributed to owning chat + AM->>AM: stage cost in pending-by-parent + Note over AM,UL: parent's next turn books
tree cost − Σ staged costs (clamped ≥ 0) +---- + +*De-inflation* is what keeps the books honest when a provider reports a *whole-tree* +cost on the parent turn while parent-turn tokens are parent-only: sub-agent costs are +staged as each settles, and the parent row is booked as tree cost minus the staged +sum. The partition is exact by construction — pricing drift lands on the parent +residual, never inflates the grand total. + +== The two sinks and their delivery + +[cols="1,2,2"] +|=== +|Sink |Shape |Delivery + +|`ActivityStatsStore` +|12 additive counters × (all-time + per-calendar-day buckets), day chosen at +increment time, ~35 days retained. Atomic writes; serialized increment queue. +|`stats.activity.get` returns five fixed rollup windows (Today, Yesterday, 7 Days, +30 Days, All Time) — no date math on the client. Metrics tiles at `/stats`. + +|`UsageLogStore` +|Itemized rows: one per turn / round / settled sub-agent, carrying the split, model, +derived cost, `kind`/`subtype`, and owning chat. 30-day window. +|The Log tab; rows grouped under their chat with sub-agent rows indented. +|=== + +Live behavior: any counter movement broadcasts a payload-free +`activity_stats_changed` ping, coalesced to at most one per ~2 s; clients invalidate +on it (the invalidation-only query convention). *Reset* is one RPC that wipes both +sinks atomically — tiles and ledger restart together, gated behind the `statsReset` +capability so old daemons are never sent a request they can't answer. Both RPCs sit +behind the `activityStats` capability flag. + +== Invariants (the audit surface) + +1. *One funnel.* Every token/cost number enters through the AgentManager sink; no + store is written from provider code. +2. *Split or nothing.* A ledger row carries a real usage split. A sub-agent that only + ever reported a scalar total gets *no* fabricated row — tokens may show with a + blank cost, but never invented classes. +3. *No inflation.* Whole-tree parent costs are de-inflated by settled sub-agent + costs, clamped at zero; the grand total can only be exact or conservatively + attributed to the parent, never double-counted. +4. *Pricing is provider-private.* No model-id price dispatch in neutral code; cost is + derived, in micro-USD integers, at event time. +5. *One row per sub-agent per run*, written at first terminal status, idempotent + against duplicate or late terminal updates. +6. *Counters and ledger reconcile.* Both sinks are projections of the same events; a + day bucket's token counters must equal the sum of that day's ledger rows. +7. *Day buckets are decided at increment time* (local daemon date) — counting + survives restarts, concurrent clients, and mobile backgrounding. +8. *Additive schema only.* Counters are individually optional leaves; adding or + retiring one is never a migration or a wire break. +9. *Reset is atomic across both sinks* and capability-gated. +10. *Nothing leaves the machine.* The audit trail lives in `$OTTO_HOME` + (`activity-stats.json`, `usage-log.json`, `stats/personality-usage.json`) and is + served only over the daemon's own authenticated RPCs. + +== Audit & onboarding checklist + +*Auditing a number:* pick a day; sum the ledger rows for that day and compare with +the day bucket (invariant 6) — a mismatch localizes to a missed funnel event or a +double write. For a chat with sub-agents, check the partition: parent residual + +Σ sub-agent rows = reported tree cost. After a reset, both surfaces must be empty — +one populated sink is a found bug. + +*Onboarding a provider:* answer the five questions in `docs/subagent-accounting.md` +(does it surface observed sub-agents; where is per-sub-agent usage; what are its +usage field names; is the parent cost whole-tree or parent-only; can it truly price) +— then implement only steps 1–3 of the adapter contract, reusing the neutral core. +Prove invariant 3 with a parent+child fixture and invariant 6 with a day-bucket +reconciliation before calling it shipped. diff --git a/archdocs/pages/06-engineering-guide.adoc b/archdocs/pages/06-engineering-guide.adoc new file mode 100644 index 000000000..3dca5d375 --- /dev/null +++ b/archdocs/pages/06-engineering-guide.adoc @@ -0,0 +1,219 @@ += Engineering & Audit Guide +:description: How to use this documentation to implement and audit — deep dives on chat sync, usage accounting, UI performance, and IDE tooling. + +This page turns the documentation set into a working method. For each of the four +historically hardest areas it gives: the drawn system, the *invariants* (statements +that must stay true — the audit surface), and a checklist for changing or auditing +the area. The pattern generalizes: every future subsystem doc should end with the +same three blocks. + +== §1 Chat synchronization state + +*Read first:* `docs/timeline-sync.md` · link:01-system-overview.html[lifecycle D3]. + +=== The sync loop (D5) + +[mermaid] +---- +sequenceDiagram + participant P as Provider adapter + participant AM as AgentManager + timeline store + participant S as Session (WS) + participant R as Stream reducer (cursor) + participant UI as Chat surface + P->>AM: AgentStreamEvent + AM->>AM: append row {epoch, seq}, persist + AM-->>S: broadcast agent_stream + S-->>R: unit {epoch, seq} + alt seq contiguous with cursor + R->>UI: accept (48 ms batched flush) + else seq <= cursor end + R->>R: drop_stale (duplicate) + else epoch mismatch + R->>R: drop_epoch (old generation) + else gap detected + R->>S: fetch_agent_timeline (paged) + S-->>R: authoritative rows, to completion + R->>UI: reconcile + end +---- + +=== The persistence contract (D6) + +[mermaid] +---- +erDiagram + AGENT_RECORD ||--|{ EPOCH : "one per run" + EPOCH ||--|{ TIMELINE_ROW : "monotonic seq" + AGENT_RECORD ||--o| PROVIDER_SESSION_HANDLE : "resume" + AGENT_RECORD }|--|| WORKSPACE : "cwd-derived path" + TIMELINE_ROW ||--o| TOOL_CALL_DETAIL : "normalized" + CLIENT_CURSOR }o--|| EPOCH : "tracks {epoch, endSeq}" + AGENT_RECORD { + string id + string lifecycleState + string cwd + } + TIMELINE_ROW { + string epoch + number seq + string timestamp "daemon-owned" + } +---- + +=== Invariants (the audit surface) + +1. The timeline is *append-only*; a new run starts a new epoch, never rewrites one. +2. `seq` is monotonic within an epoch; the daemon assigns it, clients never invent it. +3. Live streams exist for *immediacy only*; `fetch_agent_timeline` is authoritative, + and catch-up pages *to completion* — a partial catch-up is a bug, not a degraded mode. +4. Every inbound unit maps to exactly one cursor outcome: accept, drop_stale, + drop_epoch, or gap. There is no fifth path. +5. Row timestamps are daemon-owned canon; clients never trust local clocks for + ordering or display gating. +6. Optimistic client rows (the user's own message) must be reconciled against the + authoritative fetch, never double-shown. + +=== Checklist + +*Changing sync:* state which invariant your change interacts with; add/extend a test +in the reducer suite for the new decision path; verify reconnect + catch-up on a +long-running agent, not just fresh streams. *Auditing a "chat is weird" report:* +identify which invariant would have to be false, then instrument that decision point — +duplicates ⇒ (2)/(4), missing rows ⇒ (3), interleaved old runs ⇒ epoch handling, +ghost user messages ⇒ (6). + +== §2 Provider usage audit (tokens & cost) + +This area has grown into its own first-class page: +link:05-audit-architecture.html[*Audit Architecture*] — the measurement model (the +four token classes, model-tagged splits, derived cost), the provider-agnostic +accounting contract, sub-agent roll-up with parent-residual de-inflation, the two +sinks, and the *ten audit invariants*. Read that page as the system of record; what +stays here is the working method. + +=== Checklist + +*Adding a provider's accounting:* answer the five onboarding questions in +`docs/subagent-accounting.md` (Claude is the reference implementation), implement +only the three adapter steps (parse → accumulate → emit), and never price from +neutral code. Prove the no-inflation invariant with a parent+child fixture and +confirm one event reaches *both* sinks. *Auditing a number:* pick a day bucket, sum +the ledger rows for it, and compare to the counters — any mismatch localizes to the +funnel (missed event) or a store write (double count). *Auditing a reset:* both +surfaces must be empty afterward; one populated sink is a found bug. + +== §3 User-interface performance + +*Read first:* `docs/terminal-performance.md` (the daemon-side pipeline), +link:02-module-map.html[oversized-module inventory]. + +=== State topology (D10) + +[mermaid] +---- +flowchart LR + DC["DaemonClient"] + HRC["HostRuntimeController
(connection lifecycle)"] + SC["SessionContext"] + RQ["Reducer queue
48 ms batched flush"] + SS[("session-store
(Zustand, domain core)")] + PR["push-router"] + QC[("React Query cache
push-fed replica,
refetch disabled")] + CHAT["agent-stream
(virtualized chat)"] + PANES["Git / terminal / file panes"] + DC --> HRC --> SC + SC --> RQ --> SS --> CHAT + DC --> PR --> QC --> PANES + HRC -- "reconnect ⇒ targeted invalidation" --> QC +---- + +=== Invariants & budgets + +1. The React Query cache is a *replica*: `staleTime`/`gcTime` infinite, all refetch + triggers off. Data changes arrive only via push-router writes or explicit + invalidation (reconnect). A component adding `refetchOnMount` is a violation. +2. Stream units batch through the 48 ms reducer flush; nothing writes stream state to + a store per-event. +3. The web chat list virtualizes past 100 items (~50 recent mounted, estimated + heights); the bottom-anchor controller owns scroll position — features must not + fight it with their own `scrollTo`. +4. Writers are exclusive: push-router owns query-cache facts, the reducer queue owns + stream facts, stores own UI facts. A fact with two writers is a race by design. +5. Terminal rendering follows the daemon-side coalescing/backpressure invariants; + passive rendering never sends resize frames (last-interacting-client-wins). +6. Hot paths (`message.tsx`, diff pane, workspace screen) are memoized monoliths — + measure before touching, and prefer moving code *out* over adding branches *in*. + +=== Checklist + +*Adding UI state:* name the single writer first; pick store vs query-cache by that +writer, not by convenience. *Auditing jank:* walk D10 left to right — is the daemon +coalescing (terminal doc)? is the reducer batching? is the list virtualizing? is a +component over-rendering (React DevTools on the memo boundaries)? Each stage has a +budget, so the walk terminates at the guilty one. + +== §4 IDE & development-tool systems + +*Read first:* `docs/preview.md` — its design principles (token economy, +guardrail-bearing tool descriptions, daemon-enforced tab binding) are the constitution +for all IDE tooling. Then `docs/text-editor.md`, `docs/safe-unattended.md`. + +=== Service topology (D12) + +[mermaid] +---- +flowchart LR + AG["Agent tool call"] + CAT["Otto tool catalog
(native injection or MCP)"] + PERM["Permission / policy layer"] + PREV["DevServerManager
preview_* (launch.json)"] + BT["BrowserToolsBroker
browser_*"] + TAB["Real browser tab
in a connected client"] + FILES["Workspace-files session
file RPCs (read/edit)"] + ED["CM6 editor tabs
+ AI refactor"] + ART["ArtifactService"] + TERM["Terminal workers (PTY)"] + AG --> CAT --> PERM + PERM --> PREV + PERM --> BT --> TAB + PERM --> FILES --> ED + PERM --> ART + PERM --> TERM +---- + +=== Invariants + +1. *Provider-agnostic by design.* Every IDE capability is defined once in the + daemon's tool catalog and reaches all providers — natively where supported, via + MCP otherwise. A capability that works for one provider is unfinished, not done. +2. *The daemon never fakes a browser.* Browser tools broker to a real tab in a + connected Otto client, and tab binding is daemon-enforced — an agent can only + drive tabs it was handed. +3. *Guardrails live in the tool descriptions.* The tool text itself carries the + contract (what proof to show, when not to act); changing a description is a + behavioral change and gets reviewed as one. +4. *Token economy.* Tool results are engineered for small, high-signal payloads + (snapshots over screenshots, filtered logs over dumps). New tools state their + result-size discipline. +5. *Every mutation passes the policy layer.* File edits respect the edit gate + (multi-root rules), tool calls respect mode policy (deny-by-default unattended), + dev servers spawn only from `launch.json` entries. + +=== Checklist + +*Adding an IDE capability:* copy the D12 shape — daemon service + catalog tool + +client surface; write the tool description first (it is the spec); wire the policy +layer before the happy path; then prove it on a second provider before calling it +shipped. *Auditing safety:* enumerate the catalog, and for each mutating tool name +its policy gate — a tool without one is the finding. *Auditing a tool's behavior:* +diff its description against what it actually does; drift between the two is how +agents get confused. + +== Extending this set + +When a new system doc is written (from `archdocs/templates/`), it must end with the +same three blocks — drawn system, invariants, checklist. Invariants are the load-bearing +part: they are what turns documentation from description into an audit instrument. +Propose new diagrams through the link:04-diagram-catalog.html[catalog] with a stated +*why*; diagrams without a why don't ship. diff --git a/archdocs/pages/10-orchestration-index.adoc b/archdocs/pages/10-orchestration-index.adoc new file mode 100644 index 000000000..d2285da2d --- /dev/null +++ b/archdocs/pages/10-orchestration-index.adoc @@ -0,0 +1,195 @@ += Orchestration — Architecture Index +:description: Master table of contents for the agentic orchestration system: every subsystem needed to make user-authored agent graphs real. + +This is the architecture record for *Orchestration* — Otto's system for executing +user-authored graphs of AI agents across every provider, local models included. It is +deliberately kept as its own 10-series page set: the feature is in build, and merging it +into the link:00-index.html[main Otto set] is a later step (renumber, link from the +master ToC, fold subsystem gotchas into `docs/`). + +[IMPORTANT] +==== +*Two engines, one projection — and this set is being reconciled to code.* Otto executes +orchestrations two ways, discriminated by `Run.kind`: +link:13-orchestration-runs.html[*phase runs*] (a conducting agent declares a typed plan) and +link:14-orchestration-graphs.html[*graph runs*] (the daemon executes a user-authored DAG +exactly as drawn). Both project into the same `Run` + `RunPhase[]`, so one store, one page and +one client render both. + +Pages 11–16 are reconciled to code — they describe what exists, with unbuilt intent fenced +under explicit *Designed* markers. Pages 17–19 carry status banners separating their built +claims from design material; treat their unbannered detail as design input. Where any page and +the code disagree, the code wins. The correctness holes the 2026-07-25 review found are +**fixed**: cancel cascades to running children on both engines, and seats and templates are +snapshotted at run start (pages 14 and 16; remaining open holes are tracked in +`projects/graph-templates/`). + +The vocabulary settled: the third node state is *skipped*, not *excluded*, with +`RunPhase.skipReason` carrying the distinction. +==== + +[NOTE] +==== +*Where the design arguments live.* The sources behind this system — what was taken from +LangGraph, Rivet, Activepieces, Windmill, Kestra, Sim and Dify, and why — survive in +`docs/references.md` §2. (The orchestration project folder has shipped and drained; its full +working document exists only in the local `archive/`, outside the repo.) This set answers _how +it is built_; the references file answers _where each idea came from_. The forward plan — do +the graphs actually work, and which templates to build — is `projects/graph-templates/`. +==== + +== What this system is + +An *orchestration* is one execution of a *graph*: a user-authored, host-level template +of agent nodes wired together, which the daemon executes deterministically in a real +workspace. Every node is an ordinary Otto agent with a personality, a model, a tool +authority and a working directory. The root node hosts the chat you watch. + +The thesis in one line: *an orchestration is a decision the user fixes in advance that +the model would otherwise re-make, invisibly and differently, on every run.* The five +decisions worth fixing are decomposition, isolation, sequencing, verification and +authority — everything else this system does is in service of those. + +== Reading order + +[cols="1,3"] +|=== +|Page |What it answers + +|link:11-orchestration-concepts.html[11. Concepts & Domain Model] ✅ _code-true_ +|The vocabulary, the actors, the lifecycle of a graph and a run, and the honest test for +when an orchestration is worth its cost. + +|link:12-orchestration-data-model.html[12. Data Model & Protocol] ✅ _code-true_ +|The schemas both engines share, the wire rules that govern them, the three validation passes, +capability flags, the RPC and label surfaces, storage — and what is still only designed. + +|link:13-orchestration-runs.html[13. Phase Runs] ✅ _code-true_ +|The conductor-declared engine: the phase-type vocabulary, role resolution and the +missing-role hard fail, fan-out with structured judging, the keep-best loop, human gates. + +|link:14-orchestration-graphs.html[14. Graph Execution] ✅ _code-true_ +|How a graph runs: DAG scheduling, edge gating and skip semantics, prompt assembly from +templates and inputs, structured output with self-correction, validation, retry and iteration. + +|link:15-orchestration-durability.html[15. Durability] ✅ _code-true_ +|What actually survives a restart today (writing is solved, reading is not), the resume gap that +follows, and the designed model: checkpoint cadence, idempotent resume, persisted pause, and +adopting a still-live child agent. + +|link:16-orchestration-agents.html[16. Agent Binding & Provider Coverage] ✅ _code-true_ +|How a node becomes a running agent: seat resolution, tool authority, environment +isolation, prompt assembly — and what each provider (including local models) supports. + +|link:17-orchestration-designer.html[17. Designer & Authoring Surface] 📋 _design_ +|The canvas: port model, node palette, validation feedback, live run painting, the +compact/mobile story. + +|link:18-orchestration-observability.html[18. Observability & Accounting] 📋 _design_ +|Run events, the per-node record, cost roll-up into the existing ledger, Visualizer +integration. + +|link:19-orchestration-rollout.html[19. Rollout, Testing & Invariants] 📋 _design_ +|Build order, feature gating, test tiers, the numbered invariants and the audit +checklist for the whole system. +|=== + +== Master table of contents — every subsystem in the full vision + +This is the design-era decomposition, kept as the roadmap frame — it names everything the full +vision needs, **not** what exists. The ✅ pages in the reading order are the record of what is +built; do not read rows here as shipped. Bold entries have a dedicated section in this set. + +=== 1. Authoring (design time) +* **1.1 Graph document** — nodes, ports, edges, declared inputs, metadata, versioning +* **1.2 Node taxonomy** — lifecycle / control / capability families and the palette +* **1.3 Port & type system** — named ports, data types, connection legality, coercion +* **1.4 Validation** — live designer feedback vs the hard gate before execution +* **1.5 Canvas** — Drawflow wrapper, interaction model, live run painting +* 1.6 Templates & starters — bundled graphs, copy-on-edit, import/export +* 1.7 Composition — subgraphs, reuse, the 200-node-canvas defence +* 1.8 Compact authoring — what a phone can do (run and watch; edit is desktop-shaped) + +=== 2. Compilation (graph → executable plan) +* **2.1 Run projection** — graph nodes into the existing `phases[]` for old clients +* **2.2 Static analysis** — DAG check, reachability, port type check, unreachable nodes +* **2.3 Seat snapshot** — role → team member → personality → model, frozen at start +* **2.4 Cost estimate** — agents, tokens and wall-clock predicted _before_ the run + +=== 3. Execution engine +* **3.1 Scheduler** — ready set, all-inputs barrier, memoized node promises, concurrency +* **3.2 Value plane** — node output contract, artifact references, port payloads +* **3.3 Control flow** — conditional edges, router, excluded-branch propagation, merge +* **3.4 Iteration** — per-node loops (`times`, `until` + judge) and dynamic map fan-out +* **3.5 Failure policy** — per-node retry, error routing, run-level failure, skip cascade +* **3.6 Budgets & caps** — agents, tokens, cost, wall-clock; the hard stop +* **3.7 Cancellation** — user cancel, cascade to children, cleanup + +=== 4. Durability +* **4.1 Checkpoint model** — what is written, when, and where +* **4.2 Resume & idempotency** — memoization key, re-entrancy rules, replay safety +* **4.3 Pause & interrupt** — gate suspension, typed resume payloads, rehydration on boot +* **4.4 Orphan reclamation** — leases, visibility timeouts, adopting a still-live agent + +=== 5. Agent binding (Otto's differentiator) +* **5.1 Seat resolution** — team role → personality → bare model, with snapshot semantics +* **5.2 Tool authority** — per-node allowlist over the eight Otto tool groups, read-only +* **5.3 Environment isolation** — shared cwd vs per-node worktree, ports, env +* **5.4 Prompt assembly** — brief, node instruction, upstream material, artifact refs +* **5.5 Lifecycle** — spawn, whole-subtree settle, output extraction, failure detection +* **5.6 Provider coverage** — Claude, Codex, Copilot, OpenCode, Pi, OpenAI-compatible, + local models; per-provider capability matrix and the fallback ladder + +=== 6. Human in the loop +* **6.1 Gate semantics** — approve, reject, edit-the-artifact, answer a typed form +* **6.2 Remote approval** — push notification, approving from a phone, timeouts +* **6.3 Unattended interplay** — how gates behave under `dontAsk` / scheduled runs + +=== 7. Data plane +* **7.1 Node output contract** — summary, artifacts, structured fields, evidence +* **7.2 Artifact references** — workspace files vs Otto artifacts; addressing and reads +* **7.3 Structured output** — declaring a node's output schema, and what enforces it +* 7.4 Retention — how long node outputs and evidence live + +=== 8. Observability +* **8.1 Run event stream** — what the daemon emits and what clients do with it +* **8.2 Per-node record** — inputs, outputs, cost, files touched, duration +* **8.3 Live canvas** — painting run state onto the designer graph +* **8.4 Accounting** — roll-up into activity stats and the usage ledger +* 8.5 Visualizer — orchestration nodes as a live graph + +=== 9. Protocol & compatibility +* **9.1 Wire schemas** — namespacing, optionality rules, open vocabularies +* **9.2 Capability flags** — `server_info.features.*`, the single gate, COMPAT markers +* **9.3 Old-client story** — what a six-month-old client renders for a graph run + +=== 10. Security & safety +* **10.1 Authority model** — deny-by-default, enforced at spawn, never by prompt +* **10.2 Blast radius** — write isolation, what a runaway graph can touch +* **10.3 Budget as a safety control** — cost ceilings as the backstop for autonomy + +=== 11. Storage +* **11.1 Graph store** — host-level JSON, atomic writes, no migrations +* **11.2 Run store & checkpoints** — snapshot cadence, size discipline +* **11.3 Designer drafts** — unsaved working copies, session-scoped + +=== 12. Testing +* **12.1 Engine unit tests** — fake ports, deterministic schedules +* **12.2 Golden graphs** — the canonical shapes, asserted end to end +* **12.3 Tiered E2E** — mock, local AI (LM Studio), real provider + +=== 13. Delivery +* **13.1 Build order** — which pieces are cheap now and expensive later +* **13.2 Feature gating** — dev-only today, capability-gated after +* 13.3 Documentation — glossary, `docs/` fold-in, this set merged into the main ToC + +== Provenance + +Authored 2026-07-21 against the working tree at v0.6.6 + uncommitted orchestration work. +Reconciled to code 2026-07-25: pages 12–15 rewritten from source, with the agreed decisions +recorded in link:12-orchestration-data-model.html[Data Model] §"Decided, not built". +Grounded in direct source study of Rivet (`packages/core`), LangGraph.js +(`libs/langgraph/src`) and Activepieces (`flow-run`), plus published guidance from +Anthropic on agent workflow patterns and multi-agent failure modes. Sources and the +argument for each borrowed idea live in +`projects/orchestration-graphs/orchestration-design.md`. diff --git a/archdocs/pages/11-orchestration-concepts.adoc b/archdocs/pages/11-orchestration-concepts.adoc new file mode 100644 index 000000000..61d99a5be --- /dev/null +++ b/archdocs/pages/11-orchestration-concepts.adoc @@ -0,0 +1,225 @@ += Orchestration — Concepts & Domain Model +:description: The vocabulary, actors and lifecycles of the orchestration system, and the test for when an orchestration is worth its cost. + +== The nouns + +Terminology is load-bearing here: the wire, the code and the UI have historically used +different words for the same thing, and `docs/glossary.md` binds the UI labels. + +[cols="1,1,3"] +|=== +|UI term |Wire/code |Meaning + +|*Orchestration* +|`run` +|One execution instance. The Orchestrations page lists these. No user-facing surface ever +says "Run" — the short name is frozen in the protocol for length, not for display. + +|*Graph* +|`OrchestrationGraph` +|The reusable, host-level template that an orchestration executes. Parameterised by +declared inputs. Executing a graph mints a fresh orchestration. + +|*Node* +|`GraphNode` +|One unit of the graph. A capability node becomes a real Otto agent; control nodes are +executed by the daemon itself and never spawn anything. + +|*Port* +|`fromPort`/`toPort` +|A named connection point on a node. Multi-port is what makes branching legible: a gate +has `approved`/`rejected`, a check has `pass`/`fail`. _Reserved in the schema, unused by the +engine — lands with the gate node +(link:12-orchestration-data-model.html[Data Model] §"Decided, not built")._ + +|*Answers* +|`graphInputs` +|The fill-in values a user supplies when starting an orchestration, bound to the graph's +declared inputs. + +|*Seat* +|role → personality → model +|_Who_ runs a node, resolved at execution start and then frozen. + +|*Gate* +|`pause` +|A point where the orchestration stops and waits for a human. _Built for phase runs +(link:13-orchestration-runs.html[Phase Runs]); the graph-node form is designed, not built._ + +|*Prompt template* +|`PromptTemplate` (`runs.templates.*`) +|Reusable EJS prompt text, stored host-level, bound to a node via `promptTemplate`. Not a +graph — a piece of text a node's prompt renders from. + +|*Snippet* +|`PromptTemplate` with `snippet: true` +|A prompt template meant to be `include()`d by others — shared behavioural rules written once +instead of paid for in every node's prompt. +|=== + +Forbidden as UI labels: "workflow", "blueprint", "pipeline", "template" (as a noun for a +graph), and "Run" as a heading. + +*"Template" means two different things, and only one is ever a UI word.* In engineering docs +and the wire, *template* is a **prompt template** — reusable prompt text. The reusable thing +you execute is just a **Graph**: it *is* the blueprint (saved host-level, parameterized by +declared inputs, `builtIn` starters copied on edit), and the UI never calls it a template or a +blueprint. When `projects/graph-templates/` says "template", it means a starter graph — a Graph +shipped as a `builtIn`. If a sentence is ambiguous between the two, say "prompt template" or +"starter graph"; never bare "template". + +== The two flavours + +Both produce an orchestration; they differ only in who decides the shape. + +*AI orchestration* — the user supplies a prompt and parameters; a conductor agent +declares a phase plan through `start_run` and the daemon executes it. Non-deterministic +in structure, good when the subtasks genuinely cannot be predicted. + +*User orchestration* — the user draws a graph; the daemon executes it exactly as drawn. +Deterministic in structure, and the subject of this document set. + +The long-term convergence is now a recorded decision, in two halves +(link:12-orchestration-data-model.html[Data Model] §"Decided, not built"): phase runs become a +*preset graph template* once the graph engine gains a gate node and candidate fan-out; and the +conductor eventually **emits graphs rather than phase plans** — MCP graph-authoring tools let +an agent generate, save and start its own templates, made safe by construction because an +AI-authored graph passes the same shared validation gate as a human-drawn one. + +== What the user controls + +The value of this system is entirely in what a person can fix *before* the work starts. +There are five such decisions, and every subsystem in this set exists to serve one of +them: + +. *Decomposition* — which subproblems exist and where their boundaries are. A single + agent re-derives this per run, differently each time. +. *Isolation* — which work gets a clean context. A node is how a user says "this + exploration must not pollute the main thread". +. *Sequencing* — what must be true before the next thing starts. +. *Verification* — what "done" means, judged by something that is not the author. +. *Authority* — where a human must approve, and what each participant may touch. + +Parallelism is _not_ on that list. It is a consequence, not a goal. + +== The cost test + +A multi-agent system costs roughly an order of magnitude more tokens than a single chat +turn, and a large share of any measured quality gain is attributable to spending more +compute rather than to structure. So the honest gate before drawing a graph is: + +[IMPORTANT] +==== +*Would a single agent get this wrong in a way the structure prevents?* If the answer is +no, the graph is a slower, more expensive chat. If the graph cannot state how it will be +verified, it is probably not worth running at all. +==== + +Four shapes reliably pass that test: directed research (disjoint angles, then synthesis), +researched implementation (research → plan → human gate → implement → verify), feature +breakout (one unit of work per planned item), and adversarial evaluation (N attempts, one +judge). Each is documented as a golden graph in +link:19-orchestration-rollout.html[Rollout & Testing]. + +== The safety rule that shapes the engine + +Read-heavy work parallelises safely. Write-heavy work does not — not because of file +collisions alone (worktrees fix those) but because *every action an agent takes encodes +an implicit decision*, and parallel agents cannot see each other's. Two agents told to +build the same feature will disagree about things nobody specified, and the disagreement +surfaces only at integration. + +This produces a rule the engine enforces structurally rather than advising in prose: + +* *Parallelise gathering.* Fan-out is safe when the nodes only read. +* *Converge for deciding.* Anything whose parts must cohere runs through a single node, + or through an explicit brief that fixes the decisions in advance. + +== Lifecycle + +A graph and an orchestration have separate lifecycles that meet at execution. + +[mermaid] +---- +flowchart LR + subgraph Design["Design time"] + A["Graph authored
in the designer"] --> B["Saved to the
host graph store"] + B --> C{"Valid?"} + C -->|"problems"| A + end + subgraph Run["Execution"] + C -->|"yes"| D["Answers collected
in the dialog"] + D --> E["Compiled:
seats snapshotted,
run projected"] + E --> F["Running"] + F --> G{"Gate?"} + G -->|"yes"| H["Paused —
awaiting a human"] + H --> F + G -->|"no"| I["Done / failed /
canceled"] + end +---- + +Two properties of that picture matter downstream. *Saving is never blocked by validation* +— a half-built graph is a normal thing to save; validation only gates execution. And +*compilation happens once, at start*: seat resolution is frozen there, so editing a team +mid-run cannot shear a running orchestration. + +== How this sits on existing Otto systems + +Nothing in this feature invents a parallel universe. Every node's agent is an ordinary +Otto agent, which is why the expensive parts came free: + +[cols="1,2"] +|=== +|Existing system |What orchestration gets from it + +|Agent runtime (`AgentManager`) +|Spawn, supervision, whole-subtree settle detection, cancellation + +|Personalities & teams +|Seat resolution — a node names a _role_, the active team names the member + +|Workspaces & worktrees +|The execution environment. _(Per-node worktree isolation is designed, not built — parallel +writing nodes share one tree today.)_ + +|Otto tool catalog + permission modes +|Per-node authority, enforced at spawn rather than by prompt + +|Runs (`RunService`, `RunStore`) +|Persistence, status vocabulary, the gate mechanism, the Orchestrations page + +|Accounting (activity stats, usage ledger) +|Cost roll-up with no new plumbing — children are ordinary agents + +|Visualizer, chat, mobile client +|Monitoring, from anywhere, with no orchestration-specific work +|=== + +The single genuinely new runtime concept is the *graph engine*: a scheduler that holds +barriers, propagates values between nodes, and decides what runs next. Everything else is +composition. + +== Invariants + +. There is exactly one orchestrator (root) node per graph; it is not deletable and is + restored immediately if removed. +. The root is an entry point: it receives the orchestration's own prompt automatically + and has no input port. +. Agents never know they are waiting. All barrier state is held by the daemon; a node's + agent is created only once every input is satisfied. +. Seat resolution is snapshotted at execution start and never re-read mid-run. +. Tool authority is applied at spawn. No node is asked in prose to refrain from + something it has been given. +. An orchestration always lands in a chat — the root agent's — watchable from any device. +. A graph with validation problems can always be saved and never be executed. + +== Change checklist + +When changing anything in this system, confirm: + +* [ ] The glossary still matches the UI labels used (`docs/glossary.md`). +* [ ] Any new node kind states which of the five user decisions it serves. +* [ ] Any new parallelism respects gather-vs-decide. +* [ ] Any new field on the wire is optional and has an old-client answer + (link:12-orchestration-data-model.html[Data Model]). +* [ ] The cost test is still answerable for the shapes the change enables. diff --git a/archdocs/pages/12-orchestration-data-model.adoc b/archdocs/pages/12-orchestration-data-model.adoc new file mode 100644 index 000000000..ea3ffcbc9 --- /dev/null +++ b/archdocs/pages/12-orchestration-data-model.adoc @@ -0,0 +1,379 @@ += Orchestration — Data Model & Protocol +:description: The schemas both engines share, the wire rules that govern them, where validation happens, the RPC and label surfaces — and the parts still only designed. + +Every orchestration schema lives in one module, `packages/protocol/src/orchestration.ts`, with +the judge verdict beside it in `judge-verdict.ts`. There is one observable type — `Run` — and +both engines project into it. + +Everything down to *Designed, not built* describes what exists in code. + +== The shape + +[mermaid] +---- +erDiagram + RUN ||--o{ RUN_PHASE : "phases[]" + RUN_PHASE ||--o{ RUN_PHASE_CANDIDATE : "candidates[]" + RUN_PHASE_CANDIDATE |o--o| JUDGE_VERDICT : "verdict?" + RUN }o--o| ORCHESTRATION_GRAPH : "graphId (kind=graph)" + ORCHESTRATION_GRAPH ||--o{ GRAPH_NODE : "nodes[]" + ORCHESTRATION_GRAPH ||--o{ GRAPH_EDGE : "edges[]" + ORCHESTRATION_GRAPH ||--o{ GRAPH_INPUT : "inputs[]" + GRAPH_NODE ||--o{ GRAPH_OUTPUT_FIELD : "output.fields[]" + GRAPH_NODE ||--o{ GRAPH_QUERY_TOOL : "queryTools[]" + GRAPH_NODE }o--o| PROMPT_TEMPLATE : "promptTemplate" +---- + +`Run` is the durable, pushed projection. `OrchestrationGraph` is the reusable authored +template. They meet only at start time: a graph run records `graphId` and the `graphInputs` the +user filled in, and the executing run then carries its own copy of what each node was asked to +do. + +== Two declaration surfaces + +The engines differ in *who declares the work*, and that difference is a schema boundary. + +[cols="1,2,2"] +|=== +| |Phase runs |Graph runs + +|Declared by +|A conducting agent, at runtime +|A user, in the designer, ahead of time + +|Input schema +|`RunPlan` → `RunPhaseDeclaration[]` +|`OrchestrationGraph` → `GraphNode[]` + `GraphEdge[]` + +|Entry point +|The `start_run` tool +|`runs.start` against a stored graph + +|Ordering +|Declared order; `dependsOn` guards +|Drawn edges; a real DAG + +|Per-unit authority +|None — the phase type picks a role +|`access`, `tools`, `queryTools`, `autonomous` +|=== + +Both land in the same `RunPhase[]`. A graph's worker nodes project as phases of type +`graph-node` carrying edge-derived `dependsOn`, which is why a client written before graphs +existed still renders a graph run as an ordinary phase list. `draft` is a real run status: the +New Orchestration dialog mints the record before the graph is started. + +== Node and edge, as they exist + +`GraphNode.kind` is an open string vocabulary, but only two values are implemented: +`orchestrator` (exactly one per graph, the root that hosts the chat) and `agent`. A node +carries its instruction (`prompt`, `promptFromInput`, or a `promptTemplate` binding), its seat +(`role` or an explicit `model`), and its declarations: `output.fields`, `access`, `tools`, +`queryTools`, `loop`, `retry`, `timeoutMs`, `autonomous`. + +`GraphEdge` is `{ from, to }` plus `when` (a JSONata condition), `fields` (which output fields +this edge carries — selection only, never renaming), and `label`. `fromPort` / `toPort` are +**reserved in the schema and unused by the engine**; see *Designed, not built*. + +== Wire rules + +These are not stylistic. The protocol contract is absolute: an old client must parse a new +daemon's messages, and the reverse. + +* **Every added field is `.optional()`.** Absence must reproduce the previous behaviour exactly. +* **Objects are `.passthrough()`.** An unknown field survives a round trip instead of being + stripped. +* **Vocabularies are plain strings on the wire**, with known values exported as `as const` + arrays for code (`RUN_PHASE_TYPES`, `RUN_STATUSES`, `RUN_PHASE_STATUSES`, + `GRAPH_SKIP_REASONS`, `GRAPH_OUTPUT_FIELD_TYPES`, `GRAPH_QUERY_TOOL_KINDS`, + `GRAPH_NODE_KINDS`). A new value from a newer peer is data, never a parse error. +* **No `.transform()`, `.catch()` or `.preprocess()`.** Wire schemas are pure structural + declarations; normalization is an explicit post-validation pass (`normalizeJudgeOutcome`). +* **Reading an absent value is a documented decision, not a shrug.** A skipped phase with no + `skipReason` was written by an older daemon and reads as `upstream-failed` — the only skip + that existed then. + +The same posture governs the value plane: an **unknown output-field `type` validates as +"anything" rather than failing**, so an old daemon meeting a new type degrades to accepting the +value instead of refusing a graph it could otherwise run. + +[WARNING] +==== +*Declaration order is load-bearing in this file.* `zod-aot` loads +`packages/protocol/src/orchestration.ts` at codegen time, so a schema referenced before its +`const` is initialised fails the **build** with `ReferenceError: Cannot access 'X' before +initialization` even though typecheck passes. Declare a schema above its first use. +==== + +== Where validation happens + +Three passes, deliberately not merged: + +[cols="1,1,2"] +|=== +|Pass |Runs where |Effect + +|`validateOrchestrationGraph` +|Shared — daemon and designer +|**Hard gate.** Duplicate ids, exactly one Orchestrator root, edges to unknown nodes, +self-edges, cycles (Kahn), missing prompts, undeclared input references, malformed loops, +duplicate output-field keys. Anything it returns stops a run. + +|`validateEdgeConditions` +|Daemon only +|**Hard gate** on JSONata syntax. Daemon-only because the parser must not enter the client +bundle — which is why the shared validator stays dependency-free. + +|`reviewOrchestrationGraph` +|Shared +|**Advisory.** Almost-certainly-mistakes that still leave the graph executable — e.g. a +condition on an edge whose source node declares no output fields. +|=== + +Edges *into* the Orchestrator root are excluded from the cycle check: they are passive +answer-delivery, not execution dependencies, so "root kicks off A, A delivers back to root" +must not read as a cycle. + +Phase plans get their own build-time rules: unique ids, and `dependsOn` referencing only an +*earlier* phase, which keeps declared order a valid topological order. + +== Capability gating + +Three independent flags on `server_info.features`, because these shipped separately: +`agentOrchestration` (phase runs, core `runs.*`, the conductor tools), `orchestrationGraphs` +(graphs, templates, the designer, `runs.start`), and `runsDelete`. + +A client without a flag shows "update the host", never a degraded reimplementation. There are +no fallback paths. + +== RPC and tool surfaces + +All client RPCs are namespaced `runs.*` with direction suffixes. + +[cols="1,2"] +|=== +|Family |Members + +|Runs |`get_snapshot`, `start`, `cancel`, `gate_respond`, `clear`, `delete`, plus the +`updated` and `cleared` notifications +|Graphs |`graphs.list`, `graphs.save`, `graphs.delete`, `graphs.changed` +|Templates |`templates.list`, `templates.save`, `templates.delete`, `templates.changed` +|=== + +`graphs.save` on a built-in performs copy-on-edit rather than mutating the shipped graph; +`delete` refuses built-ins. `runs.delete` accepts terminal and draft runs only — deleting one +in flight is refused so a cleanup click cannot orphan live agents. + +Clients hold a replica seeded by `get_snapshot` and kept fresh by the `updated` push. Agents +reach the system through MCP tools instead — `start_run`, `get_run_status`, `wait_for_agents` +— and orchestrations never nest: `start_run` is withheld from a node's own agent. + +== Labels: the spawn-side carrier + +A node's declarations reach its agent as **labels**, not as a second protocol. The per-agent +Otto tool catalog reads them back. + +[cols="1,2"] +|=== +|Label |Carries + +|`otto.orchestration-run-id` |Which run this agent belongs to. +|`otto.orchestration-policy` |`deterministic` or `autonomous`. +|`otto.orchestration-output-fields` |The declared fields — the trigger to mint `submit_output`. +|`otto.orchestration-tool-groups` |The node's Otto tool allowlist. +|`otto.orchestration-query-tools` |The node's read-only lookups. +|=== + +This indirection is why structured output and per-node authority are provider-neutral: no +provider adapter parses a graph, and no engine code branches on a provider. See +link:16-orchestration-agents.html[Agent Binding] for the other side. + +== Storage + +Host-level, file-backed, atomic (temp + rename), serialized per id, **no migrations** — +forward compatibility is the optional-field rule doing its job. + +[cols="1,2"] +|=== +|Path |Holds + +|`$OTTO_HOME/runs/{runId}.json` |One run projection each; write-heavy — saved on every change. +|`$OTTO_HOME/orchestration-graphs/{graphId}.json` |Authored templates. Host-level because a +graph is generic; it binds to a workspace only at start. +|`$OTTO_HOME/prompt-templates/` |Prompt templates and snippets. +|=== + +== Decided, not built + +Agreed 2026-07-25. These are settled decisions awaiting implementation, not open questions. + +=== Runs are a preset graph + +The graph engine out-capabilities the phase engine on every axis except **two**: human gates, +and per-phase *candidate fan-out with keep-best* — a phase can spawn N candidates from one task +and top up until enough pass, while a graph node runs one worker per attempt. Parallelism in a +graph is drawn nodes, not candidate width. + +Once the graph engine has **both** — a gate node and candidate fan-out — the phase vocabulary +(`research → plan → … → deliver`) survives as a **starter graph template**, not a second +execution model. One scheduler, one capability set. Two prerequisites, not one; until both +land, both engines run and both are documented. + +The convergence has a second half: **the conductor eventually emits graphs, not phase plans.** +MCP graph-authoring tools (author a graph, save it as a template, start it) let an agent +generate its own orchestration shapes — and this is safe by construction, because an +AI-authored graph passes through the same shared hard gate (`validateOrchestrationGraph` + +`validateEdgeConditions`) as a human-drawn one. The tool boundary validates the declaration; +nothing executes un-checked. + +=== Ports and conditions are not competitors + +Both exist; they answer different questions, and the failure mode is using one for the other's +job. + +[cols="1,2,2"] +|=== +| |Port |Condition + +|Asks +|_Which outcome did this node produce?_ +|_Should this edge carry data, given the values?_ + +|Declared by +|The node kind, at design time +|The graph author, per edge + +|Exclusivity +|Mutually exclusive — the node fires exactly one +|Independent — several may fire, or none + +|Checkable +|Yes: the validator can require every outcome be wired +|No: an expression is opaque +|=== + +> **If the node kind knows its outcomes at design time, that is a port. If the author is +> filtering on values the node produced, that is a condition.** + +Ports serve control nodes — gate `approved`/`rejected`, check `pass`/`fail`, router one per +branch, map `each`/`done`. Conditions stay for data-driven edges between agent nodes. They +compose: an edge off the `pass` port may also carry a condition. + +*The failure to avoid* is expressing a gate's approve/reject as two conditions over a faked +`{approved: true}` field. Nothing then enforces that exactly one branch runs, nothing can ask +"what happens on reject?", and the canvas shows two identical wires instead of two labelled +sockets. **Ports land with the gate node, or the gate is built wrong.** + +=== Run values: three scopes, opt-in both ways + +Otto's nodes are separate agent sessions, not handlers sharing a heap, so shared state is *text +injected into a prompt*. Every scope decision is therefore a token-cost decision, which rules +out global-by-default. + +[cols="1,1,1,2"] +|=== +|Scope |Lifetime |Visible to |Status + +|*Edge* +|One hop +|The immediate downstream node +|✅ built — stays the default + +|*Attempt* +|One node's retries and loop iterations +|That node only +|✅ built — previous output, judge feedback. Stays **private**: failed attempts must not leak +into unrelated nodes' prompts. + +|*Run* +|The whole orchestration +|Nodes that opt in to reading +|◻ not built +|=== + +Run values are declared on the graph like `inputs` are. Two rules make them affordable: + +* **Reading is opt-in, not automatic.** A node declares that it reads a key. Automatic injection + would grow every prompt with the run — the token-economy failure this repo refuses everywhere + else. +* **Write mode is per key: `once` or `append`.** `once` makes a second write an error, which + catches two nodes disagreeing about a fact. `append` serves accumulation; its order is + **settle order**, stated rather than implied, and each entry carries its source node id so a + synthesis node can attribute. + +*Subtree scope is deliberately not a thing.* An autonomous node's children already inherit its +prompt context; a third lifetime needs a concrete demand first. + +*Open:* whether an edge condition may read run values. Today a condition sees only the upstream +node's fields and prose; widening that scope is a decision to take deliberately, not a default +to inherit. + +=== Per-node accounting is a precondition, not observability + +Two graph templates cannot be compared without cost, latency and token counts per node — so +this blocks any answer to "does orchestrating actually work". `RunPhaseCandidate` gains +`startedAt`/`completedAt`, `tokens`/`cost` rolled up from the child's own accounting, and a tool +call record (`name`, `latency`, and enough to count actions). Shape informed by AgentX-Python's +trace unit (`docs/references.md` §2.1). + +=== Grading has more than one mechanism + +Today Otto grades with exactly one: an agent judger returning a prose verdict — the most +expensive, slowest and driftiest option available. The decision is that a node's acceptance test +may be **deterministic** where the answer is checkable, and a model judge only where it is not. +The `check` node (command + expectation, no model, ground truth rather than opinion) is the +first mechanism to add. + +== Designed, not built + +Remaining design intent with no decision taken yet. Items that graduated to decisions above are +no longer repeated here. + +*Node taxonomy — the undecided remainder.* The full palette groups `kind` into three families — +*lifecycle* (`orchestrator`, `brief`), *control* (`gate`, `router`, `map`, `merge`, +`subgraph`), *capability* (`agent`, `check`). Gate and check graduated to decisions above; +`router`, `map`, `merge`, `subgraph` and `brief` remain design. Of these, **`map` — fan-out +over a list discovered at run time — is the one the use-case catalog demands hardest** +(`projects/graph-templates/use-cases.md`): one node per chapter, per call site, per job posting +cannot be drawn by hand. The grouping is borrowed in shape, not code, from Dify's block enum. + +*Per-node worktree isolation.* Every node runs in the run's cwd. Read-only fan-out is safe; +**parallel writing nodes share one tree and will conflict.** Bake-offs and migration sweeps +need `isolation: "worktree"` per node — the existing Isolation vocabulary reused verbatim. + +*A richer node output.* Beyond prose and declared fields: `artifacts: ArtifactRef[]` +(workspace paths or Otto artifact ids, passed as *references* so nothing large is inlined into +the run record); `evidence: { command, exitCode, output }` arrives with the `check` node — the +proof, not the claim. + +*Persisted pause — the data-model half.* `Run.pause = { nodeId, kind, prompt, form?, +requestedAt }` — a *discriminated* object keyed by `kind` so timeouts or event-waits can be +added without a second field, with `form` reusing the `GraphInput[]` schema so one schema +serves both the New Orchestration dialog and the gate resume form. The mechanics — uniqueness +keys, the decision-arrives-first race, the boot path — live on +link:15-orchestration-durability.html[Durability]. + +== Invariants + +. One observable type. Both engines project into `Run` + `RunPhase[]`; there is no second run + model. +. Every added field is optional, and absence reproduces prior behaviour. +. Vocabularies are open strings on the wire; known values live in code, never as a wire enum. +. No transforms in wire schemas. Normalization is an explicit later pass. +. An unknown output-field type accepts; it never fails a run. +. The shared validator stays parser-free; expression syntax is checked daemon-side only. +. A graph is validated before a run starts, never during it. +. A node's declarations travel to its agent as labels — no provider parses a graph. +. A graph run is always also a valid `Run` for a client that has never heard of graphs. +. Run records stay bounded — references, never inlined blobs. +. Schemas are declared above first use, or the build breaks. + +== Change checklist + +* Adding a field? `.optional()`, and state what absence means. +* Adding a vocabulary value? Extend the `as const` array; never narrow the wire type to an enum. +* Adding a node or edge property? Add a designer control **or** confirm it survives the + round-trip carry (`graph-doc.test.ts`) — a canvas-owned key with no control is deleted on save. +* Adding an RPC? `runs...`, and pick the gating flag. +* Does an old client still render this run as a sensible phase list? +* Referencing a schema? Check it is declared above the reference, or `zod-aot` fails the build. diff --git a/archdocs/pages/13-orchestration-runs.adoc b/archdocs/pages/13-orchestration-runs.adoc new file mode 100644 index 000000000..4cdd309d8 --- /dev/null +++ b/archdocs/pages/13-orchestration-runs.adoc @@ -0,0 +1,201 @@ += Orchestration — Phase Runs +:description: The conductor-declared engine — typed phases, role resolution, fan-out with structured judging, the keep-best loop, and human gates. + +A *phase run* is an orchestration a conducting agent declares at runtime rather than one a +user draws in advance. The conductor decides the work is team-shaped, writes a plan, and hands +it to the daemon through the `start_run` tool; the daemon executes it deterministically. + +The engine is `packages/server/src/server/orchestration/run-engine.ts` — pure control flow over +an injected port, no daemon dependencies. Its sibling is +link:14-orchestration-graphs.html[Graph Execution]; both project into the same `Run`. Who fills +a seat and what it may touch is link:16-orchestration-agents.html[Agent Binding]. + +[NOTE] +==== +*Direction:* once the graph engine gains a gate node and candidate fan-out, this engine's +vocabulary survives as a preset graph template rather than a second scheduler +(link:12-orchestration-data-model.html[Data Model] §"Decided, not built"). Until both land, +this page describes the phase engine as built — and the fan-out/judge/keep-best shape below is +the capability the collapse must preserve. +==== + +== The plan vocabulary + +The plan names *phase types*, never roles. The dispatcher maps type to role, so a plan stays +readable when a team is re-cast. + +[cols="1,1,3"] +|=== +|Phase type |Default role |Purpose + +|`research` |`researcher` |Survey code or domain; report findings, not solutions. +|`plan` |`planner` |Draft the approach. +|`refactor` |`coder` |Change structure, preserve behaviour. +|`implement` |`coder` |Build the feature. +|`design` |`designer` |Styling, layout, and human-skill text. +|`verify` |`judger` |Grade work against criteria; returns a structured verdict. +|`gate` |— (human) |Stop and ask the user. +|`deliver` |`coder` |Produce the final artifact. +|=== + +A phase declaration carries an id, type, title, the `task` handed to the agent, an optional +`role` override, `dependsOn`, `fanOut`, `keepBest`, and an optional `judge` spec. It is +schema-validated at the tool boundary, so a malformed plan is rejected before any agent spawns. + +`buildRunFromPlan` is pure and adds two structural rules: ids are unique, and `dependsOn` may +only reference an *earlier* phase. That keeps the declared order a valid topological order, +which is what lets execution be a simple forward pass. + +== Execution: a sequential pass with dependency guards + +Unlike the graph engine, this engine walks `phases[]` in declared order. `dependsOn` is a +**guard, not a scheduler**: a phase whose dependency did not reach `done` is marked `skipped` +and the pass continues. Parallelism lives *inside* a phase, as fan-out — never across phases. + +[mermaid] +---- +stateDiagram-v2 + [*] --> pending + pending --> running: execute + running --> paused: gate reached (attended) + paused --> running: approved + paused --> canceled: rejected + running --> done: all phases settled + running --> failed: no passing candidate, or a cap tripped + running --> canceled: user cancel + done --> [*] + failed --> [*] + canceled --> [*] +---- + +Roles are resolved *before* spawning: the engine asks the port for the personality filling each +required role in the active team, including the judger. **A missing role hard-fails the run and +names the gap** — no silent fallback to a bare provider. Fix the team; don't paper over it. + +=== Threading results forward + +A child agent starts a fresh session with no memory of its siblings, so a dependency's output +must travel in the prompt. `composePhaseTask` prefixes the declared task with one labelled +block per dependency, carrying that phase's representative output. That is what makes +`dependsOn` mean *build on this* rather than merely *run after this*. + +The representative output of a phase is the joined summaries of its **passing** candidates, or +of all candidates when the phase was not judged. + +Every worker task is also suffixed with a short framing that asks for a finished result rather +than a progress update, and discourages needless sub-agent fan-out. + +== Fan-out, judging, and the keep-best loop + +This is the engine's signature shape: spawn several candidates, grade each with a structured +judge, keep the passers, top up until the bar is met. + +[mermaid] +---- +flowchart TD + A[Phase starts] --> B[Resolve role + judge role] + B --> C{Role filled?} + C -->|no| F1[Run fails · names the gap] + C -->|yes| D[Spawn round: fanOut candidates] + D --> E{Judge attached?} + E -->|no| G[Phase done if any candidate] + E -->|yes| H[Judge each candidate] + H --> I[Count passers] + I --> J{passers >= keepBest?} + J -->|yes| K[Phase done] + J -->|no| L{Cap tripped?
loop rounds · agents · cancel} + L -->|no| M[Top up: keepBest - passers] + M --> H + L -->|yes| N{Any passer?} + N -->|yes| K + N -->|no| F2[Phase failed · run fails] +---- + +Mechanics worth stating exactly: + +* The first round spawns the full `fanOut`. Later rounds spawn only enough to top up to + `keepBest`, never the full width again. +* **A candidate with no verdict counts as passing.** Judging is opt-in; an unjudged phase must + not be treated as unanimously failing. +* A judged phase succeeds if at least one candidate passed; an unjudged phase succeeds if it + produced any candidate at all. +* Verdicts are parsed from the judge's final message against `JudgeVerdictSchema` + (`{verdict, score?, criteria?, summary?}`), and an unparseable verdict reads as a fail. The + outcome is a forward-compatible plain string, normalized rather than enumerated. + +[NOTE] +==== +Structured judging here is **prompt-and-parse**: the judge is a full agent and its verdict is +recovered from prose. Graph nodes get a stronger contract — a `submit_output` tool with +in-session self-correction (link:14-orchestration-graphs.html[Graph Execution]). Phase runs do +not use it. +==== + +== Human gates + +A `gate` phase is the attended-by-default guarantee. Reaching one sets the phase `blocked` and +the run `paused`, then waits for a decision. + +[cols="1,3"] +|=== +|Decision |Effect + +|Approved |Phase `done` (with the approver's note), run returns to `running`. +|Rejected |Phase `failed`, run `canceled`. The pass stops there. +|Autopilot |The gate auto-approves and the run never pauses. Explicit, per run. +|=== + +Gate decisions arrive out of band and may land before the engine starts waiting, so the service +buffers a decision registered against a phase that has not blocked yet. + +[WARNING] +==== +*A paused run does not survive a daemon restart.* Orphan recovery marks every in-flight run — +including one `paused` at a gate — as `failed`. See +link:15-orchestration-durability.html[Durability]. +==== + +== Caps and settlement + +[cols="1,1,3"] +|=== +|Cap |Default |Meaning + +|`maxConcurrency` |6 |Child agents running at once across the run. +|`maxAgents` |40 |Hard ceiling on total children; the run stops rather than sprawl. +|`maxLoopAttempts` |3 |Top-up rounds for a keep-best phase. +|=== + +The run settles `done` when the pass completes, `failed` on the first phase that produces +nothing usable or when a cap trips, `canceled` on user cancel or a rejected gate. The +headline deliverable is the output of the last completed non-gate phase, which the conductor +relays back to whoever asked. A separate AI-written run summary is generated after settling and +carried on the run as `summary` with a `summaryStatus` lifecycle. + +[IMPORTANT] +==== +*Not built here.* Phase runs have no per-phase retry, no time limit, no declared output fields, +no conditional routing, and no per-node authority — those are graph-node capabilities. There is +no token or cost ceiling on either engine, and no resume. +==== + +== Invariants + +. A plan is validated before any agent spawns: unique ids, and `dependsOn` referencing only + earlier phases. +. A missing role fails the run and names the gap. There is no fallback seat. +. `dependsOn` is a guard, not a scheduler — phases run in declared order. +. A dependency's output reaches its dependants through the prompt, never through shared memory. +. A candidate with no verdict counts as passing. +. An unparseable judge verdict is a fail, never an accidental pass. +. A rejected gate cancels the run; it never silently continues. +. Every spawned child — worker or judge — counts against the run's caps. + +== Change checklist + +* Added a phase type? Give it a default role, or state why it has none (`gate` is the only one). +* Changed judging? Re-check both directions of invariant 5 and 6 — unjudged must not fail, and + unparseable must not pass. +* Added a spawn path? It must count against `maxAgents` and pass through the concurrency bound. +* Touching gates? Confirm the pre-registration buffer still works — a decision can arrive before + the engine waits. diff --git a/archdocs/pages/14-orchestration-graphs.adoc b/archdocs/pages/14-orchestration-graphs.adoc new file mode 100644 index 000000000..9e226135d --- /dev/null +++ b/archdocs/pages/14-orchestration-graphs.adoc @@ -0,0 +1,374 @@ += Orchestration — Graph Execution +:description: How a graph runs — DAG scheduling, edge gating, prompt assembly from templates and inputs, structured output, validation, retry and iteration. + +The engine is pure control flow over an injected port +(`packages/server/src/server/orchestration/graph-engine.ts`): no daemon dependencies, unit +testable with a fake port. It decides *what runs, in what order, with what input, and +whether the answer counts*. Who the agent is and what it may touch is +link:16-orchestration-agents.html[Agent Binding]. + +== Two engines, one projection + +Otto runs orchestrations through two engines that share a single observable type. `Run.kind` +discriminates them. + +[cols="1,1,2"] +|=== +|`Run.kind` |Engine |Shape + +|absent / `phases` +|`run-engine.ts` +|A conductor-declared plan. Phases in declared order, typed +(`research · plan · refactor · implement · design · verify · gate · deliver`), with fan-out +and judging *within* a phase. Human `gate` phases pause the run. + +|`graph` +|`graph-engine.ts` +|A user-authored DAG executed exactly as drawn. Per-node authority, conditional edges, +declared output fields, retry and time limits. No gate node. +|=== + +Both project into `Run` + `RunPhase[]`, so one page, one store and one client render both. A +graph's worker nodes project as phases of type `graph-node` with edge-derived `dependsOn`, +which is why a client that predates graphs still renders a graph run as a phase list. + +== Scheduling: one memoised promise per node + +There is no wave batching and no dispatcher rescan. Each node gets one promise, created on +first demand and reused; a node awaits its upstream nodes' promises directly. A node whose +inputs are ready never waits on an unrelated branch. + +Termination is guaranteed at build time, not run time: `buildRunFromGraph` rejects a graph +that fails structural validation (Kahn's cycle check, exactly one Orchestrator root, edges +resolving to known nodes) or whose edge conditions do not parse. Loops are node annotations, +never cyclic edges. + +Because a node awaits *all* of its upstream promises before deciding anything, nothing it +reads can still change by the time it decides. That is what lets conditional edges ride the +memoised-promise model with no fixed-point pass. + +[mermaid] +---- +flowchart TD + A[Node demanded] --> B[Await all upstream promises] + B --> C{Run canceled?} + C -->|yes| S1[skipped · canceled] + C -->|no| D{Any upstream failed?} + D -->|yes| S2[skipped · upstream-failed] + D -->|no| E[Resolve incoming edges] + E --> F{Edge verdict} + F -->|condition threw| X[failed] + F -->|no edge delivers| S3[skipped · condition
or upstream-skipped] + F -->|delivers| G[Assemble task] + G --> H[Acquire semaphore · check agent cap] + H --> I[Spawn agent with node authority] + I --> J[Await, bounded by timeoutMs] + J --> K{Settled ok?} + K -->|no| R{Retry attempts left?} + R -->|yes| G + R -->|no| X + K -->|yes| L[Harvest output fields] + L --> M{Loop 'until' judge passes?} + M -->|no, attempts left| G + M -->|yes / no loop| N[done] +---- + +== Edge resolution — the gate before dispatch + +A node result is three-valued: `done`, `failed`, or `skipped`. **A skip is control flow, never +an error**, and always carries a machine-readable reason. + +[cols="1,3"] +|=== +|`RunPhase.skipReason` |Meaning + +|`condition` |An incoming edge's condition chose another branch. +|`upstream-skipped` |Everything feeding this node was itself skipped. +|`upstream-failed` |An upstream node failed. +|`canceled` |The run was canceled before this node dispatched. +|=== + +The gating rule, and the reason diamonds work: + +[quote] +____ +A drawn edge is a requirement. A node runs when **at least one edge delivers and none was +ruled out by its own condition.** An upstream that was *skipped* contributes nothing and +vetoes nothing. +____ + +A join below two conditional branches still runs off whichever branch executed, because the +pruned side arrives as `upstream-skipped` rather than as a veto. + +Conditions are JSONata (`edge-conditions.ts`), evaluated against the upstream node's output +fields at the top level plus `output` carrying its prose. JSONata because it is parsed and +evaluated, never `eval`'d — **a graph is user-authored data and must never become code the +daemon executes.** Nothing else is in scope: a condition cannot reach the filesystem, the run, +or another node. + +A condition that throws **fails the node**. It is never a quiet false — a typo would otherwise +silently prune half the graph. Syntax is checked before the run starts; the shared client +validator stays parser-free, so expression checking is daemon-side. + +`GraphEdge.fields` narrows what an edge carries. Selection only, never renaming: downstream is +a prompt, not a typed function signature. + +== Building the node's task + +A node's prompt is assembled per attempt, not stored. Three sources feed it. + +[mermaid] +---- +flowchart LR + subgraph Base["Base prompt (one of)"] + T1[promptTemplate
EJS + snippets] + T2[inline prompt
+ inputs substituted] + T3[promptFromInput
declared input value] + end + T1 -->|render fails| T2 + Base --> ASM[Assembled task] + UP[Upstream material
fields JSON, then prose] --> ASM + OUT[Output instruction
from declared fields] --> ASM + IT[Iteration context
previous output · judge feedback] --> ASM + ASM --> SP[Spawn] +---- + +=== Parameters + +A graph declares `inputs[]`; the New Orchestration dialog renders them as a form. Values reach +a node three ways: `{{inputs.key}}` substitution inside the inline prompt, `promptFromInput` +binding a whole declared input as the prompt, and template variable bindings. Validation +rejects a graph that references an input it never declared. + +=== Prompt templates (EJS) + +Templates are host records under `$OTTO_HOME/prompt-templates/`, not files. `include("id")` +resolves **against the store, not the filesystem**, nesting is capped at 5 to catch snippet +cycles, and EJS output escaping is disabled because prompts are text, not markup. A template +marked `snippet: true` is meant to be included by others. + +A node binds one with `{ templateId, variables }`. A variable is a literal, `$inputs.`, or +`$output..`. **An unresolvable reference renders empty rather than leaking its +own syntax** — `$output.classify.missing` sitting in a prompt would read to the model as an +instruction. + +**Failure degrades, never blocks.** A deleted template, a syntax error, or a host with no +template store falls back to the node's inline prompt and logs. This is the one place in the +engine where a fallback is correct: otherwise removing a shared snippet breaks every graph +that referenced it. + +The convention the starters demonstrate: the *driver instruction* stays on the node, reusable +*behavioural rules* live in a snippet. Repeating those rules per node is duplication paid for +on every dispatch. + +[WARNING] +==== +*EJS compiles templates to JavaScript that runs in the daemon process.* Acceptable today +because templates are authored locally by the machine's own user — the same trust level as a +workspace script. **The day templates become shareable or importable this is a code-execution +vector and needs an explicit trust gate.** +==== + +=== Upstream material + +Satisfied edges contribute a labelled JSON block of carried fields **first**, then the prose. +Fields are what a downstream node can act on without re-reading anything; the prose still +carries the reasoning the fields deliberately don't. A skipped branch contributes nothing — +not an empty section. + +Rule: **references, not contents.** Fields carry values and paths; anything large is a file the +next node reads with its own tools. + +== Structured output: declare, submit, validate, harvest + +A node declares `output.fields` — plain JSON descriptors (`key`, `type`, `description?`, +`required?`), never a serialized Zod schema, because the same descriptor must be wire-safe, +renderable as a designer form, and compilable to both Zod and JSON Schema. `type` is an open +vocabulary: **an unknown type validates as "anything" rather than failing**, so an old daemon +meeting a new type degrades to accepting the value. Absent `required` means required. + +`node-output.ts` is the only module that compiles descriptors, and it compiles them three ways: +to Zod for validation, to a JSON Schema input shape for the tool, and to the short prose +instruction appended to the task. + +[mermaid] +---- +sequenceDiagram + participant E as Graph engine + participant D as Daemon tool catalog + participant A as Node agent + participant S as NodeOutputStore + + E->>D: spawn with output-fields label + D->>A: register submit_output (this agent only) + A->>D: submit_output({...}) + D->>D: validate against compiled Zod + alt invalid + D-->>A: isError + precise message + A->>D: submit_output({corrected}) + end + D->>S: record(agentId, value) + A-->>E: agent settles + E->>S: take(agentId) + S-->>E: fields, or null → prose scan → fail +---- + +Two details are load-bearing and easy to undo by accident: + +* **The advertised tool input schema is permissive.** The catalog parses a tool's input before + the handler runs and *throws* on failure, so a strict shape would turn the most common + mistake — a missing field — into a thrown parse error instead of a correctable tool error. + Every field is advertised optional; requiredness is enforced in the handler. +* **`submit_output` is registered past the group and policy gates.** Those gates decide which + Otto *capabilities* a node may use. This is not a capability — it is the node's own + deliverable channel, and a deterministic node (the kind most likely to declare fields) would + otherwise have it stripped along with the `agents` group. + +This is why structured output is provider-neutral: MCP-capable seats reach the tool through the +daemon's MCP server, openai-compat seats through the daemon-owned tool loop, local models get +the identical contract. **No provider branch exists anywhere in the engine.** + +**Harvest order on settle:** the tool call, then a JSON object recovered from the final message, +then failure. The prose fallback exists because small local models often write correct JSON +instead of calling the tool, and discarding that work would be the wrong kind of strict. A node +that declares fields and delivers neither fails, naming the contract. + +The submitted-value store is in-memory by design — an entry is meaningful only between +submission and harvest moments later. The durable copy is the phase candidate's `outputFields` +on the Run record. + +== The node is a mini-orchestrator + +A node is not a model call. It is a bounded orchestration of its own: the engine runs a nested +envelope of counters around one or more real agents, and only the outermost result crosses the +node boundary. + +[mermaid] +---- +flowchart TB + subgraph RETRY["Retry envelope — maxAttempts · backoff · every attempt charged to run caps"] + subgraph LOOP["Loop envelope — times N, or until + max"] + W["Worker agent
seat · authority · task"] --> H["Harvest:
tool → prose → fail"] + H --> J{"until?
Judge agent grades"} + J -->|"fail — feedback forward"| W + end + F["Attempt threw"] -->|"backoff, re-enter from the top"| W + end + H -->|"failed"| F + J -->|"pass, or times exhausted"| OUT["Node result:
declared fields + prose"] +---- + +Reading the envelope from the inside out: + +* **One iteration** spawns a worker with the node's seat, authority and assembled task, awaits + it (bounded by `timeoutMs`, which really cancels), and harvests its output. Under `until`, a + separate judge agent grades the output against the declared criteria; the judge is spawned + with no workspace reach of its own — it grades output, it does not need the node's authority. +* **The loop** is quality: `times` re-runs work that succeeded, feeding each iteration the + previous output; `until` re-runs until the judge passes, feeding the failure reasoning + forward. Self-grading is not an exit test. +* **Retry** is resilience: it wraps the whole loop and re-enters only from the top — never from + the failure path — with every attempt charged to the run's caps. + +What crosses the boundary is deliberately small: in, the material carried by satisfied edges +(fields first, prose second); out, the declared fields and the final prose. Iterations, judge +feedback, retries and timeouts stay inside — downstream nodes never see a node's drafts. + +This is also the right mental model for designing new node kinds: a kind is a different +*envelope* (a gate waits for a human instead of a worker; a check runs a command instead of a +model; a map multiplies the envelope per item), not a different scheduler. + +== Iteration and resilience + +Three separate mechanisms, deliberately not one: + +[cols="1,1,2"] +|=== +|Mechanism |Declared |What it re-runs + +|**Loop** +|`loop.times` +|Fixed repeat. Every iteration runs, last output wins. Each iteration receives the previous +one's output. + +|**Loop** +|`loop.until` +|Quality iteration. A structured judge grades each iteration against declared criteria; a pass +ends the loop, a fail feeds its reasoning into the next attempt. Never passing within `max` +fails the node — self-grading is not an exit test. + +|**Retry** +|`retry` +|Resilience. Work that never completed. Wraps the whole node *including* its loop, with backoff +`backoffMs × multiplier^(attempt-1)`, ending early if the run is canceled. +|=== + +Two invariants protect the counters: + +* **One loop, one counter.** Retry is never re-entered from the failure path. The prior art this + design studied re-enters its executor from its own catch block with a fresh allowance at every + level, so a persistently failing step retries forever. +* **Every attempt is charged to the run.** Retries and judges spawn through the same capped path + as any other agent, counting against `maxAgents` and the concurrency semaphore. A retry is + never a private allowance. + +**Time limit** (`timeoutMs`) must *cancel*, not merely stop waiting — an abandoned agent keeps +running and keeps spending. On expiry the engine calls the port's `cancelAgent`, marks +`RunPhase.timedOut`, and fails the node; its retry policy may then catch it, and independent +branches finish normally. Otto can do this because node agents are managed processes. + +== Caps and settlement + +`maxConcurrency` bounds simultaneous agents through a semaphore; `maxAgents` is a hard run-wide +ceiling that stops the run rather than letting it sprawl; loop and retry bounds are per node. + +A run settles `canceled` if the signal aborted, `failed` on the first node failure, otherwise +`done`. Failure does not stop independent branches already in flight. The wrap-up names every +skipped node and its reason, and the graph's *deliverables* — nodes nothing else consumes — +carry their full output into it. + +[IMPORTANT] +==== +*Not built here.* Graph runs have no human gate node (gates exist only on phase runs), no +per-node candidate fan-out (parallelism is drawn nodes), no per-node turn limit or token/cost ceiling +per node, no per-node worktree isolation (parallel writing nodes share one tree), no cross-node +shared state, and no resume: a graph run in flight when the daemon stops is marked failed on +restart. `GraphEdge.fromPort` / `toPort` are reserved in the schema and unused. + +Three review-found holes were **fixed 2026-07-25**: cancel now cascades — aborting a run +really cancels its in-flight children on both engines, not just the awaits; and the cast and +the prompt templates are **snapshotted at run start**, so a mid-run team or template edit +cannot re-cast or reword a running orchestration (see +link:16-orchestration-agents.html[Agent Binding]). + +Shipped per-node behaviour and its gotchas: `docs/orchestration-node-capabilities.md` — that +page owns the subsystem detail; this one owns the flow. +==== + +== Invariants + +. A skip is never an error, and always carries a machine-readable reason. +. A run never reports done while silently omitting part of the graph. +. A node decides only after every upstream promise has settled. +. A condition that cannot be evaluated fails its node; it is never a quiet false. +. An unknown output-field type accepts; it never fails a run. +. `submit_output` reaches every provider through the per-agent Otto tool catalog — no provider + branch in the engine. +. Validation failure is a correctable tool error, not a thrown parse error. +. A template that cannot render degrades to the inline prompt; nothing else in the engine + falls back. +. Retry is one bounded loop, never re-entered from the failure path, always charged to the + run's caps. +. A time limit cancels the agent; it never just stops awaiting it. +. Graph structure and condition syntax are validated before the run starts, never during it. + +== Change checklist + +* Added a node or edge property? Confirm it survives the designer round-trip + (`graph-doc.test.ts`) — a property in the canvas-owned set with no control is **deleted on + save**. +* Changed harvest, validation, or the tool's advertised shape? Re-check that a missing field is + still a *correctable tool error* on every provider, not a thrown parse error. +* Added a fallback? Justify it against invariant 8 — the template path is the only sanctioned + one. +* Added a spawn path? It must go through the semaphore and the agent cap. diff --git a/archdocs/pages/15-orchestration-durability.adoc b/archdocs/pages/15-orchestration-durability.adoc new file mode 100644 index 000000000..d85437ad1 --- /dev/null +++ b/archdocs/pages/15-orchestration-durability.adoc @@ -0,0 +1,208 @@ += Orchestration — Durability +:description: What actually survives a restart today, the resume gap that follows from it, and the designed model for closing it. + +Durability matters more here than in any framework we studied, for one reason: *our nodes +have side effects that cost money and edit repositories.* Re-running a completed node is not a +wasted millisecond — it is a second agent making a second set of edits. + +Human gates as a control-flow mechanism are link:13-orchestration-runs.html[Phase Runs]; this +page is about what persists. + +== What exists today + +*Writing is solved. Reading is not.* + +Every state change is persisted and broadcast: the engine emits the whole `Run` on each phase +transition, and `RunStore` writes it atomically (temp + rename), serialized per id. Node +results land on `RunPhaseCandidate` as `summary` and validated `outputFields`. Graphs and +prompt templates persist the same way. No migrations — forward compatibility is the +optional-field rule. + +In memory only, and lost on restart: the memoised node promises, the concurrency semaphore, +the spawned-agent count, `NodeOutputStore` (which holds a `submit_output` value for the moments +between submission and harvest), and pending gate waiters. + +[WARNING] +==== +*There is no resume.* `RunService.init()` walks the persisted runs at boot and marks every +`running`, `pending` **or `paused`** run as `failed` with "Daemon restarted while this run was +in flight." A run parked at a human gate — waiting on the person the gate exists for — does not +survive a daemon restart. Meanwhile its child agents may still be alive, so the work is +orphaned rather than cleaned up. + +This is the single largest gap in the system, and everything below is the plan for closing it. +==== + +What *is* enforced today: a run in flight cannot be deleted (the caller must cancel first, so a +cleanup click cannot orphan live agents), gate decisions arriving before the engine registers +its wait are buffered in memory, and a starved join already resolves as `skipped` with a stated +reason rather than hanging or silently vanishing. + +== Designed: what must survive a restart + +. *The frontier* — which nodes completed, which were skipped, which were in flight. +. *Node outputs* — so a resumed run feeds downstream nodes without re-running upstream ones. +. *Pause state* — which gate is waiting, what it asked, and what form it expects back. +. *The seat snapshot and spend* — resolution must not drift, and a restart must not reset the + counter. + +The first two are largely already written; the frontier is the piece that lives only in the +promise map. + +=== Checkpoint cadence + +Two distinct persistence events, and conflating them is a common bug: + +[cols="1,3"] +|=== +|Event |When + +|*Node settle* +|As each node produces a result. A node that produced nothing still writes a marker, so "ran, +produced nothing" stays distinguishable from "never ran". + +|*Run snapshot* +|Once per scheduling step, written *after* the completed step's results are applied and +*before* the next ready set is prepared. +|=== + +=== Idempotent resume + +Two studied systems solve this differently, and the difference decides ours. + +[cols="1,2,2"] +|=== +| |LangGraph.js |Activepieces + +|Mechanism +|Deterministic task ids (`uuid5` over namespace, step, node, trigger, seeded by checkpoint id) +plus persisted per-task writes. On resume, recompute the id; skip if a non-error write exists. +|Re-walk the step tree and short-circuit per step against a name-keyed output map. + +|Node body on resume +|*Re-executes from the top.* No continuation capture. +|Skipped entirely when the step is present and not paused. + +|Fits us? +|The id derivation is machinery we do not need — our node ids are stable and user-authored. +|*Yes.* This is the model. +|=== + +**Otto's rule:** seed the memoised result map from the persisted run before execution starts. A +node whose phase is `done` resolves immediately to its recorded output; a node that is `skipped` +resolves skipped; everything else runs. + +[IMPORTANT] +==== +*The memo check must precede anything side-effecting, always.* LangGraph's node bodies +re-execute from the top on resume — harmless when a node is a function, catastrophic when a node +spawns an agent that edits a repository. Node execution must be structured so that nothing — no +spawn, no file write, no notification — precedes the "have I already done this?" check. +==== + +*The key must include the iteration.* A plain `(runId, nodeId)` key breaks the moment a node is +inside a loop: the same node id legitimately runs many times. The key is +`(runId, nodeId, attempt, instance)`. This is not hypothetical — Rivet's user-input pause is +keyed by node id alone, so a looped input node cannot have two outstanding prompts. + +=== Gate pause and resume + +[mermaid] +---- +flowchart TD + A[Gate reached] --> B[Persist pause: nodeId, prompt, form] + B --> C[Run paused · notify, incl. push] + C --> D{Decision arrives} + D -->|approve / answer| E[Store payload keyed by nodeId + iteration] + D -->|reject| F[Approved branch skipped] + E --> G[Clear pause · resume scheduling] + F --> G + C -.->|daemon restarts| H[Boot: run is paused — rehydrate, never fail it] + H --> D +---- + +Three details are load-bearing: + +*Uniqueness is the idempotency anchor.* One outstanding question per node instance, keyed +`(runId, nodeId, iteration)` and enforced by the store rather than by convention, so a +double-click or a retried RPC cannot create two pending gates. + +*Handle the decision-arrives-first race.* A user can respond before the engine finishes +persisting the pause. Otto already solves this in memory; the durable version needs the same +buffering, persisted. + +*The boot path is the whole fix for indefinite gates.* Activepieces' boot-time "refill paused +jobs" pass is a one-shot migration guarded by a Redis key, **not** a watchdog — their durable +mechanism is a job scheduled at pause time. Otto's equivalent is simpler: a paused run is a +record nothing is driving, so the boot path only needs to stop marking it failed and restore it +as paused. Gates *with timeouts* would need the scheduled-job equivalent. + +*Resume payloads are keyed, never positional.* LangGraph matches resume values to `interrupt()` +calls by index within the task, so changing the number or order of interrupts silently +misassigns answers. Key by node id and iteration from day one. + +=== Orphan adoption + +The genuinely hard case, and the one no studied framework has: *the daemon restarted and the +node's agent is still running.* Every framework surveyed assumes in-process work, so a crash +kills it. Ours does not — Otto agents are separate processes with persisted state. + +That makes the correct behaviour *adoption*, not restart: + +. On boot, for each in-flight run, look up each running node's `agentId`. +. Agent exists and unsettled → **re-await it**. The node continues as though nothing happened. +. Agent exists and settled → harvest its output as the node's result. +. Agent gone → the node counts as failed, subject to its failure policy. + +This is strictly better than any in-process engine can offer, and it falls out of Otto's +existing architecture rather than needing new machinery. + +A lease with a visibility timeout is the backstop for two daemons driving one run — currently +impossible, but the invariant ("a node that is `running` is owned by exactly one executor, and +ownership is provable") is worth stating before it becomes possible. + +== Storage discipline + +The run record is broadcast to every connected client on every change, so it must stay small. +Outputs are references plus a bounded summary; anything large belongs in the workspace or the +artifact store. Activepieces offloads step outputs into a compressed log and slices anything +over 32KB into separate objects — a reasonable design for their shape, and an argument for +ours: *if the output is big enough to need slicing, it should have been an artifact.* + +== Deliberately not building + +* *Reducers with declared merge semantics.* LangGraph needs them because concurrent nodes write + shared state channels. Our concurrent writers write *files*, and the filesystem already has + ownership semantics. +* *Time travel / fork from a checkpoint.* Real value, real cost, and per-agent rewind already + exists. +* *Deterministic replay with compensation.* Requires all nondeterminism behind activity + boundaries. Our side effect is "an agent edited your repo" — git is the compensation + mechanism, not us. + +== Invariants + +Marked ✅ where the code enforces it today and ◻ where it is designed. + +. ◻ A node's result is persisted as it settles, distinguishably from "never ran". +. ◻ The memo check precedes every side effect in node execution. +. ◻ The memoisation key includes the loop attempt and map instance, not just the node id. +. ◻ Pause state is persisted on the run, unique per `(runId, nodeId, iteration)`. +. ◻ Resume payloads are keyed by node identity, never by call position. +. ✅ A gate decision arriving before the engine waits is buffered, not lost *(in memory)*. +. ◻ A restart resumes in-flight runs; it never marks a paused run failed. +. ◻ A still-running agent is adopted, not re-spawned. +. ✅ A join that can never be satisfied resolves skipped with a stated reason — never a hang, + never a silent skip. +. ✅ A run in flight cannot be deleted; the caller cancels first. +. ✅ Writes are atomic and serialized per run id. +. ◻ The run record stays bounded; anything large is an artifact. + +== Change checklist + +* Does the change introduce a side effect that could precede the memo check? +* Does it add a way for the same node to run twice — and if so, is the key extended? +* Can a decision or event arrive before the state it answers is persisted? +* Does a restart mid-change leave a run in a state the boot path understands? +* Does it add anything unbounded to the run record? +* Is there a durability test that kills the daemon at this exact point? diff --git a/archdocs/pages/16-orchestration-agents.adoc b/archdocs/pages/16-orchestration-agents.adoc new file mode 100644 index 000000000..72bd41063 --- /dev/null +++ b/archdocs/pages/16-orchestration-agents.adoc @@ -0,0 +1,170 @@ += Orchestration — Agent Binding & Provider Coverage +:description: How a node becomes a running agent — seat resolution, the three authority narrowings, lifecycle, and what each provider actually enforces. + +This is the layer no orchestration framework has, and where Otto's advantage lives. Rivet, +LangGraph and every visual builder surveyed treat a node as a function or a model call. Here a +node is *a named agent, with a personality, a model, an authority and a working directory, +running as a supervised OS process*. + +What a node *declares* is link:12-orchestration-data-model.html[Data Model]; how the engine +*schedules* it is link:14-orchestration-graphs.html[Graph Execution]. This page is the seam +between a declaration and a live process. + +== Seat resolution + +A node names a *role*, not a person. At spawn, `resolveTeamRoleMember` finds the active team +member carrying that role; a node with no role uses its explicit `model`. **A role nothing +fills fails loudly and names the gap** — never a silent fallback to a default model the user +did not choose. Roles are what keep graphs portable: the same graph runs on an all-local-model +team and an all-Claude team, because the graph names the job, not the brain. + +**Resolution is snapshotted at run start** (fixed 2026-07-25): the graph path freezes the team +view, the personality roster and the prompt-template store once, at start — every node seat, +every composed team prompt and every template render resolves against that frozen view, so a +mid-run team or template edit cannot shear a running orchestration. The phase-run path gets +the same guarantee from its per-run role cache. This is also what makes a run reproducible, +which the evaluation harness depends on. + +== Authority: three narrowings, one direction + +The rule: *authority is applied at spawn, never requested in prose.* An agent told in its +prompt not to use a tool it has been given will eventually use it. Every mechanism below +withholds; none asks. + +[cols="1,2,2"] +|=== +|Narrowing |Declared |Enforced + +|*Tool policy* +|`autonomous` flag → `deterministic` or `autonomous` label +|Deterministic strips orchestration, preview and browser tools; autonomous grants the Otto +toolset minus `start_run`. Orchestrations never nest. + +|*Otto tool groups* +|`GraphNode.tools` — allowlist over the eight groups +|**Intersected** with the policy and the daemon-wide allowlist — a node narrows its own +authority, never widens it. An empty array is meaningful: "no Otto tools at all". Also a cost +lever: the catalog is paid in input tokens per request, and a smaller catalog measurably helps +small models stay on task. + +|*Workspace access* +|`GraphNode.access` — `none` / `read` / `write` (absent ⇒ `write`) +|Rides on `AgentSessionConfig.workspaceAccess`; each provider adapter withholds its own tools +(table below). A boundary, not an instruction — a prompt injection cannot reach a tool that +does not exist. +|=== + +Plus *query tools* — author-defined read-only lookups scoped to one node's session, read-only +by construction (argv with `shell: false`, GET-only with no author headers, path-checked +reads). + +Two consequences worth stating plainly: + +* **Narrowing-only has a real casualty: the browser-verified verifier.** A deterministic node + can never gain `preview`/`browser` — the intersection can only shrink the policy baseline — + and the only way to grant them is full `autonomous`, which grants far more. Given Preview is + a founding capability of this fork, "a verify node with exactly preview + terminals" is a + hole, not a corner case. +* **Provider-native sub-agents are a separate axis, still ungated per node.** Suppressing them + (Claude's `disallowedTools`) is what would make a deterministic node fully deterministic; + today only Otto tools are gated. + +== How declarations travel + +Everything a node declares reaches its agent as **labels** (the table is on +link:12-orchestration-data-model.html[Data Model]); the per-agent Otto tool catalog reads them +back and mints `submit_output`, registers query tools, and applies the group allowlist. No +provider adapter parses a graph, and no engine code branches on a provider — this indirection +is the entire provider-neutrality story. + +== Lifecycle + +[cols="1,3"] +|=== +|Step |Mechanism + +|Spawn +|`createAgentCommand` with the resolved seat, the labels above, parented to the orchestrator +agent, bound to the run's workspace. + +|Settle +|`waitForAgentFullySettled` — *whole subtree*. An autonomous node that spawned helpers is +re-invoked when they finish and writes its real answer afterwards; settling on first-idle +would capture a premature one. + +|Extract +|The `submit_output` store is *taken*, not read — one submission belongs to one settle, so a +later iteration can never inherit an earlier answer. No submission → balanced-JSON recovery +from the final message → failure naming the contract. + +|Failure +|Agent status `error`, or the settle call throwing → a `failed` node result, into the engine's +retry policy. +|=== + +Because every node is an ordinary agent, parentage, the usage ledger, activity stats, the +sub-agents track and the Visualizer all work with no orchestration-specific plumbing. That is +the compounding benefit of not inventing a parallel execution universe. + +== Provider coverage + +Most of this page is provider-neutral by construction. The one axis that differs per provider +is **workspace access enforcement**, and a provider that cannot enforce it **refuses the node +at spawn** — running a `read` node with full access is precisely the failure the feature +exists to prevent. + +[cols="1,3"] +|=== +|Provider |How `access` is imposed + +|*openai-compat* (incl. local models) +|Total: the daemon owns the tool loop, so forbidden specs are withheld and the model is never +told they exist. + +|*Claude* +|`applyWorkspaceAccess` adds the level's denied tools to `disallowedTools` and strips them from +`allowedTools`, applied after the dontAsk allowlist so a deny always wins. + +|*Codex* +|Mapped onto its native sandbox tiers as a **ceiling** — the seat's tier can be narrowed, never +widened. + +|*Everything else* +|Not supported: the spawn is refused, naming the node, the level and the provider. Never set +`supportsWorkspaceAccess` without the enforcement behind it. +|=== + +[NOTE] +==== +*Local AI is a design constraint, not a nice-to-have.* A graph is worth more to a small local +model than to a frontier one, because it supplies exactly what a smaller model is worst at +holding: decomposition, sequencing and a definition of done. The engine already leans this +way — permissive tool schemas with handler-side validation, prose recovery for models that +write JSON instead of calling the tool, narrow catalogs. Any node feature that silently +requires a frontier model must degrade, or be declared as needing a capable seat at spawn +rather than failing mid-run. +==== + +== Invariants + +. A node names a role; a role nothing fills fails loudly, naming the gap. +. Authority is enforced by withholding at spawn, never by prompt. All three narrowings only + narrow. +. `start_run` is withheld from every orchestration participant — orchestrations never nest. +. Declarations travel as labels; no provider parses a graph, no engine code branches on a + provider. +. Whole-subtree settle, never first-idle. +. A submission is taken once; an iteration can never inherit an earlier one. +. A provider that cannot enforce `access` refuses the node; it never silently runs it wide. +. The cast and the templates are frozen at run start; nothing mid-run can re-cast or reword a + running orchestration. +. A canceled run cancels its in-flight children — the cascade, on both engines. +. ◻ _Designed:_ per-node worktree isolation; native sub-agent suppression per node. + +== Change checklist + +* Does this work on a local-model seat? If not, is that declared at spawn time? +* Does the new capability change what a node may touch? Then it belongs in a narrowing, not in + the prompt. +* Adding an enforcement path? Confirm the refuse-at-spawn rule holds for providers that lack it. +* Adding a narrowing? Confirm it intersects — a node must never be able to widen. diff --git a/archdocs/pages/17-orchestration-designer.adoc b/archdocs/pages/17-orchestration-designer.adoc new file mode 100644 index 000000000..2d699ee6f --- /dev/null +++ b/archdocs/pages/17-orchestration-designer.adoc @@ -0,0 +1,171 @@ += Orchestration — Designer & Authoring Surface +:description: The graph canvas — port model, node palette, validation feedback, live run painting, drafts, and the compact-device story. + +The designer is a workspace tab (web + Electron; native gets a placeholder). It is the +only surface where a user expresses structure, so its job is to make the *consequences* +of structure visible — what runs in parallel, what waits, what a human approves, what +proves the work. + +[NOTE] +==== +*Mixed built/design.* The canvas, node cards with the Advanced disclosure, the one-per-line +authoring forms, round-trip carry of unedited properties, drafts and repair-on-load are built +and documented in `docs/orchestration-node-capabilities.md` §"Authoring these in the designer" +— that page is authoritative for shipped authoring behaviour. Multi-port rendering, the +control-node palette entries and live run painting are design, pending the matching engine +work. The whole surface is dev-only until the designer matures. +==== + +== Architecture + +A vendored, frozen Drawflow bundle (MIT) plus an Otto-owned TypeScript wrapper. None of +the vendor's own UI comes along; Otto owns the toolbar, the node cards, the palette and +the theme. The bundle is never formatted, linted or edited — `**/vendor/**` is +ignore-listed, and the MIT notice lives beside it because the upstream build strips its +own banner. + +The split that matters — *canvas here, executor in the daemon* — is not an Otto +invention; Rivet arrived at the same shape independently, with an isomorphic executor +decoupled from any database and a WebSocket debugger that lets its IDE attach to a +processor running elsewhere. That is a strong signal we have the boundary in the right +place, and it is also the argument for the live-run feature below. + +== Port model in the UI + +Ports are arrow-shaped, ride *outside* the card border, and read hollow until wired and +solid once connected. Output ports carry the accent colour, inputs the warm counter-hue. + +Multi-port is the change the designer must absorb next +(link:12-orchestration-data-model.html[Data Model]): a gate has `approved`/`rejected`, a +check has `pass`/`fail`, a router has one port per branch. Requirements: + +* Every port renders a label. An unlabelled second output is unusable. +* Ports are ordered and stable — a port's vertical position must not move when an + unrelated setting changes, or wires appear to jump. +* Dynamic ports (map, subgraph) derive from data: a subgraph node's ports come from the + child graph's declared inputs and outputs, recomputed when the child changes. + +=== Connection legality has exactly one implementation + +If ports become typed, *the same function must answer "may I draw this wire?" and "may I +pass this value?"*. Rivet is the cautionary tale: its editor-side check is advisory +styling only, its runtime coercion is separate, and the source carries the comment that +the two are hard to keep in sync. The result is that an illegal connection is drawable +and fails at runtime, which is the worst of both worlds — the editor implied a guarantee +it does not provide. + +== Easy snap + +A 13-pixel port is a miserable drop target and Drawflow only connects on a pixel-perfect +release. While a wire is in flight the nearest input inside a radius attaches — it fills +and scales, and the wire's loose end jumps to it — and releasing anywhere lands there. +Implemented as a capture-phase `mouseup` that hands Drawflow's own `dragEnd` a synthetic +event whose target is the snapped port. + +== Node palette + +Grouped by the three families, which is also how the Add menu reads: + +* *Lifecycle* — Orchestrator (root, structural, undeletable), Brief +* *Control* — Gate, Router, Map, Merge, Subgraph +* *Capability* — Agent, Check + +A node card is a title bar (type prefix, inline-editable name, delete), a body with the +fields that matter at a glance (role, prompt), and an Advanced disclosure for the +machinery (prompt-from-input, model override, tool authority, isolation, loop). The +principle: *a node reads clean until you need the machinery.* + +Declared graph inputs surface inside nodes — a hint line listing the `{{inputs.key}}` +references available, and a prompt-from-input select over the declared keys, both +refreshed live as the Inputs sheet changes. + +== Validation feedback + +Two levels, and conflating them is a UX failure we already made once: + +* *Save is never blocked.* A half-built graph is a normal thing to save. Save failure — + the host rejected the write — is the only red. +* *Validation gates execution only*, reported as a warning with the count and the first + blocker named, not as an error that implies the work was lost. + +Run saves first, then opens the New Orchestration dialog with this graph preselected so +the user fills in Answers and confirms. The designer never executes directly, which keeps +"what am I about to spend" in one place. + +== Live run painting — target + +The daemon already emits run events and the client already subscribes; wiring them to the +canvas turns the designer into the run monitor. Node visual states, borrowing Rivet's +vocabulary because it is well-chosen: + +[cols="1,3"] +|=== +|State |Painting + +|`running` +|Accent pulse, elapsed timer, current tool if reported + +|`ok` +|Solid, with the output summary available on the card + +|`error` +|Danger border, the message on the card + +|`notRan` / excluded +|Greyed, *with the reason* — "branch not taken", "upstream failed", "run canceled" + +|`paused` +|The gate node highlighted, with the approve/answer affordance inline +|=== + +Carrying the *reason* on an excluded node is what makes a conditional graph debuggable; +without it, a greyed node is indistinguishable from a bug. + +[IMPORTANT] +==== +*Throttle partial output.* Rivet throttles per-node partial outputs to ~100ms before they +reach the IDE. A graph with six concurrent agents streaming tokens into a canvas will +otherwise saturate the transport and the render loop — and on a phone, the battery. +==== + +== Drafts + +The tab unmounts on every workspace switch, and a graph is a document: leaving the room +is not discarding. A session-scoped working copy is held per host and graph, so returning +finds the canvas exactly as it was, still marked unsaved. Nothing reaches the host until +the user saves. + +== Repair on load + +Graphs outlive schema changes. When a stored graph references a port that no longer +exists on a node kind, the loader *drops that connection and continues* rather than +refusing to open the graph — the same defensive behaviour Rivet applies when it snapshots +port definitions at preprocess time. A graph that cannot be opened cannot be repaired by +the user; one that opens with a missing wire can. + +== Compact devices + +The dialog and the execute/monitor flow are cross-platform and are the priority: starting +an orchestration, watching it, and *approving a gate* must all work from a phone — +approving a plan from anywhere is one of the strongest arguments for this whole feature. +Authoring is desktop-shaped; native shows a placeholder pointing at the desktop. A mobile +designer remains a stretch goal, never a commitment. + +== Invariants + +. The vendored canvas bundle is never edited, formatted or linted. +. The orchestrator root is undeletable and restored immediately if removed. +. Saving is never blocked by validation; only execution is. +. Every port has a visible label, and port order is stable across unrelated edits. +. If ports are typed, one function decides legality for both editor and engine. +. An excluded node always paints its reason. +. Streaming into the canvas is throttled. +. Unsaved edits survive in-app navigation. + +== Change checklist + +* [ ] Does a new node kind declare its ports, their labels and their order? +* [ ] Does a new field belong in the body (glanceable) or Advanced (machinery)? +* [ ] Does the change keep save unblocked and validation advisory? +* [ ] If it adds live data to the canvas, is it throttled and battery-sane on mobile? +* [ ] Can a graph saved before this change still open, with a stated repair if not? diff --git a/archdocs/pages/18-orchestration-observability.adoc b/archdocs/pages/18-orchestration-observability.adoc new file mode 100644 index 000000000..0d9a3310c --- /dev/null +++ b/archdocs/pages/18-orchestration-observability.adoc @@ -0,0 +1,136 @@ += Orchestration — Observability & Accounting +:description: The run event stream, the per-node record, cost roll-up into the existing ledger, and what makes a graph tunable rather than a slot machine. + +A graph you cannot inspect is a slot machine. Every visual builder surveyed ships a +per-node execution log with inputs and outputs, and it is the feature that turns "this +graph is bad" into "this node's prompt is bad". + +[NOTE] +==== +*Status.* The transport half is built: the engine persists and pushes the full `Run` on every +change (`runs.updated.notification`), phases carry statuses, skip reasons, candidates with +summaries and output fields, retry counts and `timedOut` — so a client can already paint +per-node state from its replica without any new event types. What is *not* built is the +per-node measurement record (timings, tokens, cost, tool calls) — now **decided and promoted +to a precondition** of the template initiative +(link:12-orchestration-data-model.html[Data Model] §"Decided, not built") — and the run +estimate/budget events. The event names below are design vocabulary for what the snapshot +push must come to carry, not a proposed side channel. +==== + +== The event stream + +The daemon already emits a run snapshot on every state change and persists it — the +engine mutates a working `Run` and calls a persist-and-emit path that snapshots, +broadcasts and stores. Clients keep a replica fed by pushes rather than polling. + +What the stream needs to carry for orchestration, beyond today's phase statuses: + +[cols="1,3"] +|=== +|Event |Why + +|`node.started` +|Paints the canvas and starts the elapsed timer. + +|`node.output` +|The structured result — summary, artifact refs, declared fields, evidence. + +|`node.excluded` +|*With a reason.* Branch not taken, upstream failed, run canceled. Without the reason a +greyed node is indistinguishable from a bug. + +|`node.progress` +|Optional, throttled: current tool, tokens so far. This is what makes a long node feel +alive rather than hung. + +|`run.paused` +|A gate is waiting. Drives the notification and the approve affordance. + +|`run.budget` +|Spend against the ceiling, so the user can stop a run before it stops itself. +|=== + +Throttling is a requirement, not an optimisation: six concurrent agents streaming into a +phone over a relay will saturate transport, render loop and battery. Rivet throttles +per-node partial output to roughly 100ms; that is the right order of magnitude. + +== The per-node record + +`RunPhaseCandidate` already carries `agentId`, `personalityId` and `summary`. What makes +a graph *tunable* is the rest: + +* `startedAt` / `completedAt` — where the wall-clock actually went +* `tokens` / `cost` — per node, rolled up from the child agent's own accounting +* `filesTouched` — the write-isolation story, and the honest answer to "what did this + thing do to my repo" +* `output` — the structured result, which doubles as what resume reads +* `attempt` — which loop iteration this was, so a retry ladder is legible + +== Accounting + +This is the part that came free and must stay free. Every node is an ordinary Otto agent, +parented to the orchestrator, so activity stats, the usage ledger, per-personality stats +and the sub-agent roll-up all work with no orchestration-specific plumbing. The rules +from the audit architecture apply unchanged: + +. *No inflation.* A child's tokens are counted once. The parent's residual is + de-inflated so a run's total is not the sum of double-counted parts. +. *Cost is derived from tokens and a model's price*, never reported independently. +. *Local-only.* Nothing leaves the machine. + +The one orchestration-specific addition is a *run total* — what this orchestration cost, +end to end, visible on the run and next to the estimate that was shown before it started. +At an order-of-magnitude premium over a chat, estimate-versus-actual is the number that +decides whether a user trusts the feature twice. + +== Live canvas + +Covered in link:17-orchestration-designer.html[Designer]; the point here is that it needs +no new transport. The daemon emits, the client subscribes, the canvas paints. Rivet +proves the shape works — its IDE attaches over WebSocket to a processor running elsewhere +and paints node state from the same handlers it uses for local runs, so remote and local +are identical downstream. We already have both halves. + +One lesson from its protocol worth *not* copying: its frames are asymmetric (`{type, +data}` inbound, `{message, data}` outbound) with no versioning. Otto's RPC namespacing and +compatibility rules already prevent that class of problem — keep orchestration events +inside them rather than inventing a side channel. + +== Visualizer + +An orchestration is a parent agent with children, which is exactly what the Visualizer +already renders. No orchestration-specific work is required for it to show a run; the +open question is whether a graph's *drawn* topology should override the Visualizer's +force layout for orchestration runs. That is a genuine product choice, not a technical +constraint, and it is deferred. + +== What "good" looks like + +A user should be able to answer, after a run and without reading the chat: + +* Which node was slow, and which node was expensive. +* Which nodes ran in parallel, and which waited on which. +* Why a node did not run. +* What each node actually produced, as an artifact they can open. +* Whether the estimate was honest. + +If any of those needs a transcript read, the observability layer is incomplete. + +== Invariants + +. Every excluded node carries a machine-readable reason, and the UI shows it. +. Node cost is counted once; run totals never double-count children. +. Cost is always derived, never independently reported. +. Streaming to clients is throttled and bounded. +. Orchestration events live inside the existing RPC namespacing and compatibility rules. +. Accounting requires no orchestration-specific plumbing — if it ever does, the node has + stopped being an ordinary agent and that is the bug. + +== Change checklist + +* [ ] Does the new event carry a reason where it represents a negative outcome? +* [ ] Is it throttled, and bounded in size? +* [ ] Does it round-trip through the existing push-replica machinery? +* [ ] Does the per-node record still answer the five "good" questions above? +* [ ] Does an old client tolerate the new event (ignore-unknown)? diff --git a/archdocs/pages/19-orchestration-rollout.adoc b/archdocs/pages/19-orchestration-rollout.adoc new file mode 100644 index 000000000..723dc79df --- /dev/null +++ b/archdocs/pages/19-orchestration-rollout.adoc @@ -0,0 +1,234 @@ += Orchestration — Rollout, Testing & Invariants +:description: Build order, feature gating, golden graphs, test tiers, and the consolidated audit checklist for the whole orchestration system. + +== Build order + +The design-era staging below is retained for its rationale, with what has since shipped marked. +**The live, agreed order is the dependency list in +`projects/graph-templates/graph-templates.md`:** durability boot path → per-node accounting → +gate + ports → check → run values → turn limit → map → isolation — then the golden-graph +harness. Of the correctness holes the 2026-07-25 full-set review found, the cancel cascade and +the seat/template snapshots are **fixed** (same day, engine-tested); the +deterministic-node-needs-preview widening is settled with the gate step. + +[cols="1,2,3"] +|=== +|Stage |Work |Status + +|*0 — Structural* +|Named ports on edges; the third node result state +|Third state ✅ shipped as `skipped` + `skipReason`. Ports reserved in the schema, unused — +land with the gate. + +|*1 — Make it real* +|Structured node output; Check node; Gate node with durable pause; Brief node; per-node tool +groups; worktree isolation +|Structured output ✅ (fields + `submit_output` + prose recovery); tool groups, query tools, +workspace access ✅. Check, gate, brief, isolation ◻. + +|*2 — Make it powerful* +|Conditional edges + Router; Map fan-out; Merge with a policy; Subgraph +|Conditional edges ✅ (JSONata on the edge; router-as-node not needed for two-way branches). +Map, merge policy, subgraph ◻. + +|*3 — Make it trustworthy* +|Run budget with a hard stop; per-node failure policy; checkpoint restore + agent adoption; +the per-node record +|All ◻. Per-node record is promoted to a *precondition* of the template initiative, not a +stage-3 nicety. +|=== + +Deliberately out of scope: reducers with declared merge semantics, time-travel/fork from a +checkpoint, deterministic replay with saga compensation. Real primitives in LangGraph and +Temporal; problems we do not have at this scale +(link:15-orchestration-durability.html[Durability]). + +== Feature gating + +Two gates, one check each. + +* *Dev-only, for now.* While the designer is under construction, every door in — the New + Orchestration button, the designer tab, running a graph orchestration — additionally + requires a dev build (`isDev`, Metro's `__DEV__`, dead-code-stripped from production + bundles). Release builds keep the Orchestrations page exactly as it was. +* *Capability-gated, after.* `server_info.features.orchestrationGraphs`, detected in + exactly one place client-side, with a `COMPAT(orchestrationGraphs)` marker naming the + version and the cleanup condition. No fallback paths: a client either has a capable host + or tells the user to update it. + +Daemon ships before client, always — the capability flag exists so a new client can detect +an old host, not the reverse. + +== Golden graphs + +The four shapes that pass the cost test, each shipped as a starter and asserted end to +end. They double as the acceptance criteria for the stages above. + +[cols="1,2,2"] +|=== +|Graph |Shape |Proves + +|*Directed research* +|Brief → N researchers on declared disjoint angles (read-only, parallel) → synthesis → +verification against sources +|Fan-out, barrier fan-in, deliverable extraction + +|*Researched implementation* +|Research → plan artifact → *gate* → implement (isolated) → check → review by a different +seat → loop until the check passes +|Gates, artifacts, isolation, ground-truth verification, loop-until-pass + +|*Feature breakout* +|Plan emits a list → *map* one implementer per item → each self-checks → integration → +verify +|Dynamic fan-out, per-instance isolation, ordered collection + +|*Adversarial evaluation* +|Same task N ways in parallel → judge panel → keep best +|Best-of-N, judging, merge policy +|=== + +== Test tiers + +[cols="1,3"] +|=== +|Tier |What it covers + +|*Engine unit* (vitest, fake port) +|The whole of link:14-orchestration-graphs.html[Engine] with no daemon: barriers, excluded +propagation, loop bounds, failure cascade, cancellation, budget trips. Deterministic +schedules — no timing, no randomness. + +|*Protocol* +|Schema round-trips, old-client parse of a graph run, unknown-vocabulary tolerance. + +|*Durability* +|Kill and restart mid-run. A resumed run must not re-spawn a completed node's agent, must +adopt a still-running one, and must never mark a paused run failed. This is the tier that +catches the expensive class of bug. + +|*App* +|Designer interactions, draft survival across navigation, validation feedback, gate +approval UI. + +|*E2E, three tiers* +|Mock provider (structure), local AI via LM Studio (real models, no cost), real provider +(the honest end-to-end). Per `projects/e2e-qa-coverage`. +|=== + +The house rule stands: never run a full suite locally — run the file you changed, push to +CI for breadth. + +== Migration + +There is nothing to migrate *to*, by design. Every schema change here is additive with +absent-means-today semantics, so existing graphs keep working untouched. Two behaviours +make that true in practice: + +* A stored graph whose edges carry no ports runs exactly as it does today. +* A stored graph referencing a port that no longer exists opens with that connection + dropped rather than refusing to open. + +Built-in starters are re-seeded only when absent, so a user's edits are never overwritten. +Changing a starter's shape means users who already have it keep the old one until they +delete it. + +== Consolidated invariants + +The per-page invariants, gathered for audit. Each is checkable against the code. + +*Structure* + +. Exactly one orchestrator root per graph; undeletable, restored immediately if removed. +. The root has no input port — it receives the orchestration's prompt automatically. +. A graph with validation problems can always be saved and never be executed. +. Edges with no ports mean `output` → `input`; existing graphs never need rewriting. + +*Execution* + +[start=5] +. A node's agent is spawned only when every input has resolved; barrier state lives in the + daemon, and agents never know they are waiting. +. Node results are three-valued; `excluded` is not an error and never fails a run. +. Whether a kind can consume an excluded input is declared on the kind, not listed inside + the scheduler. +. Concurrency is bounded and total agents are capped, always. +. Every loop and fan-out has a hard maximum and a declared at-maximum action. +. Cancel and failure are distinct terminal states, each with a stated reason. +. A join that can never be satisfied resolves excluded with a reason — never a hang, never + a silent skip. + +*Agents* + +[start=12] +. A node names a role; the person is resolved once and frozen. +. A role nothing fills is a loud compile error, never a silent default. +. Authority is applied at spawn, never requested in prose. +. `start_run` is stripped from every participant — orchestrations never nest. +. Whole-subtree settle, never first-idle. +. No node feature may require a specific provider without declaring it at compile time. + +*Data & protocol* + +[start=18] +. Every added field is optional and its absence reproduces prior behaviour. +. Vocabularies are plain strings on the wire with known-value arrays in code. +. Node outputs are references plus a bounded summary — never inlined file contents. +. A graph run is always also a valid `Run` for a client that has never heard of graphs. + +*Durability* + +[start=22] +. A node result is persisted as it settles, distinguishably from "never ran". +. The memo check precedes every side effect in node execution. +. The memoisation key includes loop attempt and map instance, not just the node id. +. Pause state is persisted, unique per `(runId, nodeId, iteration)`, and resume payloads + are keyed by node identity rather than call position. +. A restart resumes in-flight runs and adopts still-running agents; it never marks a + paused run failed. + +*Transparency* + +[start=27] +. Every excluded node carries a machine-readable reason. +. Node cost is counted once; run totals never double-count children. +. Cost is derived from tokens and price, never independently reported. +. An estimate is shown before a run, and the actual is shown against it after. + +== Audit checklist + +Run this against the implementation, not the documentation: + +* [ ] Grep the scheduler for a hardcoded list of node kinds. There should be none — + behaviour is declared on the kind. +* [ ] Kill the daemon mid-run. Does the run resume, avoid re-spawning completed nodes, and + adopt an agent that is still alive? +* [ ] Draw a conditional branch into a join. Does the join resolve with a reason when one + branch is pruned — rather than hanging or silently reporting success? +* [ ] Put a gate inside a loop. Are two iterations' questions distinguishable? +* [ ] Give a node a role no team fills. Is the error loud and specific? +* [ ] Set a budget below the graph's estimate. Does the run refuse to start, or stop + cleanly at the ceiling? +* [ ] Run a graph on a local-model seat. What degrades, and was it declared? +* [ ] Open a graph saved before the current schema. Does it open? +* [ ] Confirm an old client renders a graph run as a sensible phase list. + +== Open questions + +Two of the original five are now decided +(link:12-orchestration-data-model.html[Data Model] §"Decided, not built"): the AI flavour +**does** eventually emit graphs — phase runs become a preset graph once the gate node and +candidate fan-out land, and MCP graph-authoring tools are the convergence path — and +structured output shipped as *enforced with in-session self-correction plus prose recovery*, +which resolves the enforce-vs-best-effort tension in both directions at once. + +Still open: + +* Should a graph's drawn topology override the Visualizer's force layout for orchestration + runs? +* Where does a gate's timeout belong: per gate, per run, or a host default? And what is the + correct behaviour when a gate is reached during an unattended scheduled run + (`docs/safe-unattended.md`)? +* Do subgraphs need a cycle check on graph references? Rivet has none, and a graph that + includes itself is a trivially reachable footgun. +* May an edge condition read run values, once run values exist? diff --git a/archdocs/serve.mjs b/archdocs/serve.mjs new file mode 100644 index 000000000..ef0969642 --- /dev/null +++ b/archdocs/serve.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Tiny static server for the built architecture docs (archdocs/dist). +// No dependencies — local browsing only. +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { dirname, extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const distDir = join(dirname(fileURLToPath(import.meta.url)), "dist"); +const port = Number(process.env.ARCHDOCS_PORT ?? 4400); + +const types = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", +}; + +createServer(async (req, res) => { + const path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)); + const file = join(distDir, path === "/" || path === "\\" ? "index.html" : path); + if (!file.startsWith(distDir)) { + res.writeHead(403).end(); + return; + } + try { + const body = await readFile(file); + res.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream" }); + res.end(body); + } catch { + res.writeHead(404, { "content-type": "text/plain" }).end("not found"); + } +}).listen(port, "127.0.0.1", () => { + console.log(`archdocs: serving on http://127.0.0.1:${port}`); +}); diff --git a/archdocs/templates/erd.adoc b/archdocs/templates/erd.adoc new file mode 100644 index 000000000..089546891 --- /dev/null +++ b/archdocs/templates/erd.adoc @@ -0,0 +1,41 @@ += [Domain name] — Data Model (ERD) +:description: One sentence: what state this domain owns. +// TEMPLATE — copy into archdocs/pages/, keep every section, delete the comments. +// Budget: ≤ 10–12 entities per diagram. Attributes: only the identity, ordering, and +// discriminator fields — never the full schema (the Zod schema is the source of truth). + +== Scope + +// What state this covers, where it lives at rest ($OTTO_HOME path, store file, +// AsyncStorage key…), and which module owns writes. + +== Diagram + +[mermaid] +---- +erDiagram + ENTITY_A ||--|{ ENTITY_B : "relationship" + ENTITY_A { + string id + } +---- + +== Entities + +[cols="1,2,1,1"] +|=== +|Entity |Meaning |At rest |Write owner + +|… |… |`$OTTO_HOME/…` |`packages/…` +|=== + +== Consistency rules + +// Keying rules, atomicity, compat constraints (append-only? no migrations? keyed by +// cwd vs workspaceId?). These are the invariants for this domain. +1. … + +== Source anchors + +* `packages/…` — schema +* `packages/…` — store diff --git a/archdocs/templates/process-flow.adoc b/archdocs/templates/process-flow.adoc new file mode 100644 index 000000000..f9d07e963 --- /dev/null +++ b/archdocs/templates/process-flow.adoc @@ -0,0 +1,51 @@ += [Flow name] — Process Flow +:description: One sentence: what this flow accomplishes. +// TEMPLATE — copy into archdocs/pages/, keep every section, delete the comments. +// Budget: ≤ 6 participants, ≤ 20 messages per sequence diagram. Model the happy path +// plus the failure branches that change behavior — not every conceivable error. + +== Purpose & trigger + +// When does this flow run, who starts it, what is true when it ends. + +== Actors + +[cols="1,2"] +|=== +|Actor |Role in this flow + +|… |… +|=== + +== Flow + +[mermaid] +---- +sequenceDiagram + participant A as Actor + participant B as Actor + A->>B: … + alt failure branch + B-->>A: … + end +---- + +== Failure modes + +// The branches that matter operationally: what fails, what the user sees, how it recovers. +[cols="1,2,2"] +|=== +|Failure |Observable symptom |Recovery + +|… |… |… +|=== + +== Invariants + +1. … + +== Source anchors + +// The 3–6 files a reader opens to verify this page. Paths only, no line numbers +// (they rot). +* `packages/…` diff --git a/archdocs/templates/system-overview.adoc b/archdocs/templates/system-overview.adoc new file mode 100644 index 000000000..56887619f --- /dev/null +++ b/archdocs/templates/system-overview.adoc @@ -0,0 +1,47 @@ += [System name] — System Overview +:description: One sentence: what this system is for. +// TEMPLATE — copy into archdocs/pages/, keep every section, delete the comments. +// Budget: the whole page ≤ 200 lines; every diagram ≤ 15–20 nodes. + +== Mission + +// 2–4 sentences: what this system guarantees to the rest of Otto, and for whom. + +== Boundary + +// What is inside vs deliberately outside. Bullets, not prose walls. +* Inside: … +* Outside (and where it lives instead): … + +== Structure + +// One Mermaid flowchart of the subsystems (≤ 15 nodes). If you need more nodes, +// split a child page and link it — do not grow the diagram. +[mermaid] +---- +flowchart LR + A["…"] --> B["…"] +---- + +[cols="1,2,2"] +|=== +|Subsystem |Responsibility |Key modules (2–4 paths) + +|… |… |… +|=== + +== Key flows + +// Link process-flow pages (or catalog entries) rather than inlining every sequence. +* link:…[Flow name] — one line on when it runs. + +== Invariants + +// Numbered, testable statements. These are the audit surface — write them so a +// reviewer can check each one against code. +1. … + +== Change & audit checklist + +// 3–6 bullets: what to do before changing this system; how to audit a report against it. +* … diff --git a/archdocs/templates/technical-design.adoc b/archdocs/templates/technical-design.adoc new file mode 100644 index 000000000..b7cc42ca8 --- /dev/null +++ b/archdocs/templates/technical-design.adoc @@ -0,0 +1,63 @@ += [Feature name] — Technical Design +:description: One sentence: the change this design proposes. +// TEMPLATE — copy into archdocs/pages/ (or projects// while in flight). +// A design that cannot fill the Compatibility section is not ready for review. + +== Context + +// The problem, in terms of the systems in the link:00-index.html[master ToC]. +// Link the system-overview pages this touches instead of re-explaining them. + +== Goals / non-goals + +* Goal: … +* Non-goal: … + +== Design + +// Prose first, then ONE diagram of the changed shape (≤ 15 nodes) — draw the delta, +// not the whole world. + +[mermaid] +---- +flowchart LR + A["…"] --> B["…"] +---- + +== Alternatives considered + +[cols="1,2,2"] +|=== +|Alternative |Why plausible |Why rejected + +|… |… |… +|=== + +== Compatibility + +// The two contracts from CLAUDE.md, answered explicitly: +* *Protocol contract:* fields added (all optional?), enum values gated behind which + client capability, old-client/old-daemon parse story. +* *Feature contract:* which `server_info.features.*` flag, where the single + capability check lives, the `COMPAT(name)` cleanup marker. + +== Provider coverage + +// A capability isn't done when one provider has it. State the rollout: +[cols="1,1,2"] +|=== +|Provider |Status |Notes + +|Claude |… |… +|OpenAI-compat |… |… +|ACP family |… |… +|=== + +== Rollout & verification + +// Flags, ordering (daemon before client?), and the proof: which test tier, what +// the browser-verified evidence is. + +== Open questions + +* … diff --git a/archdocs/theme.css b/archdocs/theme.css new file mode 100644 index 000000000..09f411ae6 --- /dev/null +++ b/archdocs/theme.css @@ -0,0 +1,216 @@ +/* Otto architecture docs — minimal professional theme, light/dark aware. */ +:root { + --bg: #ffffff; + --fg: #1c2128; + --muted: #57606a; + --border: #d8dee4; + --accent: #0969da; + --code-bg: #f6f8fa; + --sidebar-bg: #f6f8fa; + --warn-bg: #fff8c5; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --muted: #9198a1; + --border: #30363d; + --accent: #4493f8; + --code-bg: #161b22; + --sidebar-bg: #10151c; + --warn-bg: #3a3520; + } +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: + 16px/1.6 -apple-system, + "Segoe UI", + Roboto, + Helvetica, + Arial, + sans-serif; +} + +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 280px; + flex-shrink: 0; + padding: 24px 16px; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} +.brand { + font-weight: 700; + font-size: 18px; + margin-bottom: 20px; +} +.nav-links { + display: flex; + flex-direction: column; + gap: 2px; +} +.nav-links a { + color: var(--muted); + text-decoration: none; + padding: 6px 10px; + border-radius: 6px; + font-size: 14px; +} +.nav-links a:hover { + color: var(--fg); + background: var(--border); +} +.nav-links a.active { + color: var(--accent); + font-weight: 600; +} + +.content { + flex: 1; + min-width: 0; + max-width: 980px; + padding: 40px 48px 96px; +} + +h1 { + font-size: 32px; + line-height: 1.25; + margin: 0 0 8px; +} +h2 { + font-size: 24px; + margin-top: 40px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} +h3 { + font-size: 19px; + margin-top: 28px; +} +h4 { + font-size: 16px; +} +a { + color: var(--accent); +} +p, +ul, +ol { + margin: 0 0 12px; +} +.sectionbody > .paragraph:first-child p { + margin-top: 8px; +} + +code { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; + font: + 13px/1.5 ui-monospace, + "Cascadia Code", + Consolas, + monospace; +} +pre { + overflow-x: auto; +} +pre code { + display: block; + padding: 12px 14px; + border-radius: 8px; +} +.listingblock pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + font: + 13px/1.5 ui-monospace, + "Cascadia Code", + Consolas, + monospace; +} + +pre.mermaid { + display: flex; + justify-content: center; + background: transparent; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + overflow-x: auto; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 12px 0 20px; + font-size: 14px; +} +th, +td { + border: 1px solid var(--border); + padding: 7px 11px; + text-align: left; + vertical-align: top; +} +th { + background: var(--code-bg); +} + +.admonitionblock { + border-left: 4px solid var(--accent); + background: var(--code-bg); + border-radius: 0 8px 8px 0; + margin: 16px 0; +} +.admonitionblock td.icon { + display: none; +} +.admonitionblock td.content { + border: none; + padding: 10px 14px; +} +.admonitionblock.warning, +.admonitionblock.caution { + border-left-color: #d4a72c; + background: var(--warn-bg); +} + +.quoteblock { + border-left: 4px solid var(--border); + margin: 0 0 12px; + padding: 4px 16px; + color: var(--muted); +} + +@media (max-width: 860px) { + .layout { + flex-direction: column; + } + .sidebar { + width: 100%; + height: auto; + position: static; + } + .content { + padding: 24px 20px 64px; + } +} diff --git a/branding/README.md b/branding/README.md new file mode 100644 index 000000000..3eb98637b --- /dev/null +++ b/branding/README.md @@ -0,0 +1,89 @@ +# Otto brand assets + +Otto's mark is a robot face built from the letters of the name: reading O·T·T·O left to +right gives _eye, brow, brow, eye_. The two O's are the eyes, each T's crossbar floats +above the neighboring O as an eyebrow, and the two T stems form the nose bridge between +the eyes. The design is inspired by boxy 80's-movie robots without copying any of them. + +## Files + +| File | What it is | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `otto-logo.svg` | Full logo: robot layer (`#robot`) + wordmark layer (`#wordmark`), 512×512 | +| `otto-icon.svg` | Icon: face only (eyes, brows, nose bridge), single solid pupil per eye, 512×512 | +| `otto-icon-small.svg` | Small-size icon. Currently identical geometry to `otto-icon.svg` — the single solid pupil already reads cleanly at 48px and below, so there's no separate ring-dropping step anymore. Kept as its own file so a future redesign can reintroduce small-size-specific simplification without touching the full-size master. | +| `otto-icon-wink.svg` | Expression variant: raised left brow + winking right eye. Not shipped as the app icon; reserved for fun surfaces (stickers, success states). | +| `otto-icon-wink-small.svg` | Small-size wink variant, same relationship to `otto-icon-wink.svg` as `otto-icon-small.svg` has to `otto-icon.svg` — currently identical geometry. | + +All masters draw with `stroke="currentColor"` (and `fill="currentColor"` on solid pupils) +so they preview in any color context. Keep that convention when editing in Inkscape or +similar: an ungroup operation resolves inherited `currentColor` into a literal hex value +on each element, which silently breaks recoloring for every generated asset. Re-wrap +edited shapes in a `` (or re-apply `stroke="currentColor"` +directly) before saving, and save as **Plain SVG** — Inkscape's default save format embeds +`sodipodi:`/`inkscape:` editor metadata that the generator script's regex-based extractor +can't parse, and the whole run will throw an XML namespace error. + +## Geometry contract + +Everything is mirror-symmetric around x=256 (the wink variant is the deliberate +exception). Rules that keep the mark coherent when editing: + +- **T ratio:** each crossbar has its stem exactly ⅓ from the inner end (logo: 84-long + bar, 28 inner / 56 outer; icon: 96-long bar, 32 / 64). Less than that stops reading + as a letter T; more stops reading as an eyebrow. Unchanged by the July 2026 redesign. +- **Eyes overlap the stems:** each O now sits far enough inward that its inner edge + (center ± radius ± half stroke) lands _past_ its stem's centerline rather than + exactly on it — the stem visually enters the ring, reading closer to a lowercase + "d"/"b" than a tangent touch (logo: eyes at 171.87/340.13, overlap 9.87; icon: eyes + at 145.57/366.43, overlap 13.57). This replaces the older "eyes touch the stems" + rule — deliberate as of the July 2026 redesign, not a regression. +- **Brow gap:** crossbar bottom edge floats 10 units (logo) / 20 units (icon) above the + eye's top edge. Unchanged — this only depends on each eye's cy/r, which the redesign + didn't touch. +- **Baseline:** stems end at the same y as the O's outer bottom edge (logo 330, icon 364). + Unchanged for the same reason. +- Icon face bounding box is ~61.6..450.4 × 148..364 — still centered on (256,256), but + narrower horizontally than before (was 48..464) since the eyes moved inward. + +## Two-layer loading pulse + +`#wordmark` and `#robot` in `otto-logo.svg` are separate layers on purpose: the app's +startup splash keeps the wordmark solid and fades the robot layer in and out as the +loading pulse. Keep any new detail in the correct layer — nothing in `#wordmark` may +animate, and `#robot` must remain legible-optional (the wordmark alone must read as +OTTO). + +## Regenerating shipped assets + +All raster and derived assets (app icons, favicons + status variants, splash, PWA, +desktop `.ico`/`.icns`, website logo/favicon) are generated from these masters: + +```bash +node scripts/generate-brand-assets.mjs +``` + +Edit the masters (or the tile/badge parameters in the script), re-run, and commit the +regenerated files. Do not hand-edit the generated files — the script is the source of +truth for sizes, tile radii, and status-badge colors (blue `#3b82f6` running, green +`#22c55e` attention, matching `use-favicon-status.ts`). + +## Dev-build icons + +The same run also writes `packages/desktop/assets/dev/` — the desktop icon and the +Windows/Linux tray icons over a navy tile (`DEV_TILE_COLOR`, blue-900 `#1e3a8a`) +instead of the shipped black one. Running the installed Otto and a dev Otto side by +side is the expected setup (see [docs/development.md](../docs/development.md) → +"Lanes"), and identical black icons make the two taskbar buttons and tray +entries indistinguishable. + +These never reach a release build. `packages/desktop/src/features/dev-icon.ts` only +looks in `dev/` when `!app.isPackaged`, and nothing in `electron-builder.yml` copies +that folder — `files:` ships `dist/**` only, `extraResources:` names individual asset +files, and `linux.icon: assets` scans for loose `NxN.png`. A missing `dev/` folder +falls back to the normal icon, so a checkout that has not run the generator still +launches. + +There is no navy variant of the macOS **idle** tray icon: that one is a template +image, and macOS re-tints template images for the menu-bar theme, so the fill would +be discarded. It falls back to the shipped asset. diff --git a/branding/otto-icon-android.png b/branding/otto-icon-android.png new file mode 100644 index 000000000..b0c1ab1ce Binary files /dev/null and b/branding/otto-icon-android.png differ diff --git a/branding/otto-icon-small.svg b/branding/otto-icon-small.svg new file mode 100644 index 000000000..c6c1784ed --- /dev/null +++ b/branding/otto-icon-small.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/branding/otto-icon-wink-small.svg b/branding/otto-icon-wink-small.svg new file mode 100644 index 000000000..8cfafbdde --- /dev/null +++ b/branding/otto-icon-wink-small.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/branding/otto-icon-wink.svg b/branding/otto-icon-wink.svg new file mode 100644 index 000000000..995c7af53 --- /dev/null +++ b/branding/otto-icon-wink.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/branding/otto-icon.svg b/branding/otto-icon.svg new file mode 100644 index 000000000..537b576ed --- /dev/null +++ b/branding/otto-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/branding/otto-logo.svg b/branding/otto-logo.svg new file mode 100644 index 000000000..a80712fd9 --- /dev/null +++ b/branding/otto-logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cli-client-id b/cli-client-id new file mode 100644 index 000000000..a86f22c08 --- /dev/null +++ b/cli-client-id @@ -0,0 +1 @@ +cid_518a41c4c44340aea1120d2b760fc6c6 \ No newline at end of file diff --git a/docker/Dockerfile.agents.example b/docker/Dockerfile.agents.example new file mode 100644 index 000000000..188286524 --- /dev/null +++ b/docker/Dockerfile.agents.example @@ -0,0 +1,17 @@ +# Example child image that adds agent CLIs to the official Otto image. +# +# Build: +# docker build -f docker/Dockerfile.agents.example -t otto-with-agents . +# +# Then set `image: otto-with-agents` in docker/docker-compose.example.yml. + +FROM ghcr.io/draek2077/otto:latest + +USER root +RUN npm install -g \ + @anthropic-ai/claude-code \ + @openai/codex \ + opencode-ai + +# Leave the image user as root. The base entrypoint prepares mounted volumes, +# then drops the daemon and launched agents to the non-root `otto` user. diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..136148ce5 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,30 @@ +# Otto Docker Image + +This directory contains the official Otto daemon image. + +The image runs the daemon headless and serves the bundled web UI from the same +HTTP origin. Start it, then open the daemon URL in a browser. + +```bash +docker run -d --name otto \ + -p 6868:6868 \ + -e OTTO_PASSWORD=change-me \ + -v "$PWD/otto-home:/home/otto" \ + -v "$PWD:/workspace" \ + ghcr.io/draek2077/otto:latest +``` + +Then open `http://localhost:6868`. + +The base image intentionally does not bundle agent CLIs. Extend it with the +agents you use: + +```Dockerfile +FROM ghcr.io/draek2077/otto:latest + +USER root +RUN npm install -g @openai/codex @anthropic-ai/claude-code +``` + +See [docs/docker.md](../docs/docker.md) for Compose, reverse proxy, security, +agent auth, and troubleshooting notes. diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile new file mode 100644 index 000000000..96df928c5 --- /dev/null +++ b/docker/base/Dockerfile @@ -0,0 +1,105 @@ +# syntax=docker/dockerfile:1 + +ARG NODE_IMAGE=node:24-bookworm-slim +FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack + +ARG OTTO_VERSION + +ENV ONNXRUNTIME_NODE_INSTALL=skip + +WORKDIR /tmp/otto-src +COPY . . + +RUN set -eux; \ + if [ -n "${OTTO_VERSION:-}" ]; then \ + test "$(node -p "require('./package.json').version")" = "${OTTO_VERSION}"; \ + fi; \ + node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \ + npm ci + +RUN set -eux; \ + mkdir -p /tmp/otto-packs; \ + npm pack --workspace=@otto-code/highlight --pack-destination /tmp/otto-packs; \ + npm pack --workspace=@otto-code/relay --pack-destination /tmp/otto-packs; \ + npm pack --workspace=@otto-code/protocol --pack-destination /tmp/otto-packs; \ + npm pack --workspace=@otto-code/client --pack-destination /tmp/otto-packs; \ + npm pack --workspace=@otto-code/brain --pack-destination /tmp/otto-packs; \ + npm pack --workspace=@otto-code/server --pack-destination /tmp/otto-packs; \ + npm pack --workspace=@otto-code/cli --pack-destination /tmp/otto-packs + +FROM ${NODE_IMAGE} + +ENV HOME=/home/otto \ + OTTO_HOME=/home/otto/.otto \ + OTTO_LISTEN=0.0.0.0:6868 \ + OTTO_WEB_UI_ENABLED=true \ + OTTO_LOG_FORMAT=json \ + OTTO_LOG_LEVEL=info \ + CLAUDE_CONFIG_DIR=/home/otto/.claude \ + CODEX_HOME=/home/otto/.codex \ + XDG_CONFIG_HOME=/home/otto/.config \ + XDG_DATA_HOME=/home/otto/.local/share \ + XDG_STATE_HOME=/home/otto/.local/state \ + XDG_CACHE_HOME=/home/otto/.cache \ + ONNXRUNTIME_NODE_INSTALL=skip + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + gosu \ + lbzip2 \ + openssh-client \ + procps \ + tini; \ + rm -rf /var/lib/apt/lists/* + +COPY --from=source-pack /tmp/otto-packs /tmp/otto-packs +RUN set -eux; \ + npm install -g /tmp/otto-packs/*.tgz; \ + rm -rf /tmp/otto-packs; \ + npm cache clean --force; \ + server_entry="$(npm root -g)/@otto-code/server/dist/scripts/supervisor-entrypoint.js"; \ + test -f "$server_entry"; \ + printf '%s\n' "$server_entry" > /etc/otto-server-entry; \ + node --check "$server_entry" + +RUN set -eux; \ + existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \ + if [ -n "$existing_group" ] && [ "$existing_group" != "otto" ]; then \ + groupmod --new-name otto "$existing_group"; \ + elif [ -z "$existing_group" ]; then \ + groupadd --gid 1000 otto; \ + fi; \ + existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \ + if [ -n "$existing_user" ] && [ "$existing_user" != "otto" ]; then \ + usermod --login otto --gid otto --home /home/otto --shell /bin/bash "$existing_user"; \ + elif [ -z "$existing_user" ]; then \ + useradd --uid 1000 --gid otto --create-home --home-dir /home/otto --shell /bin/bash otto; \ + fi; \ + mkdir -p \ + /workspace \ + "$OTTO_HOME" \ + "$CLAUDE_CONFIG_DIR" \ + "$CODEX_HOME" \ + "$XDG_CONFIG_HOME" \ + "$XDG_DATA_HOME" \ + "$XDG_STATE_HOME" \ + "$XDG_CACHE_HOME"; \ + chown -R otto:otto /home/otto /workspace + +COPY docker/base/rootfs/ / +RUN chmod +x /usr/local/bin/otto-docker-entrypoint + +WORKDIR /workspace + +EXPOSE 6868 +VOLUME ["/home/otto"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD node -e "const listen=process.env.OTTO_LISTEN||'0.0.0.0:6868'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6868; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/otto-docker-entrypoint"] diff --git a/docker/base/rootfs/usr/local/bin/otto-docker-entrypoint b/docker/base/rootfs/usr/local/bin/otto-docker-entrypoint new file mode 100644 index 000000000..ad3d7cb7e --- /dev/null +++ b/docker/base/rootfs/usr/local/bin/otto-docker-entrypoint @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_HOME="/home/otto" + +: "${HOME:=$IMAGE_HOME}" +: "${OTTO_HOME:=${HOME}/.otto}" +: "${OTTO_LISTEN:=0.0.0.0:6868}" +: "${OTTO_WEB_UI_ENABLED:=true}" +: "${OTTO_LOG_LEVEL:=info}" +: "${OTTO_LOG_FORMAT:=json}" +: "${CLAUDE_CONFIG_DIR:=${HOME}/.claude}" +: "${CODEX_HOME:=${HOME}/.codex}" +: "${XDG_CONFIG_HOME:=${HOME}/.config}" +: "${XDG_DATA_HOME:=${HOME}/.local/share}" +: "${XDG_STATE_HOME:=${HOME}/.local/state}" +: "${XDG_CACHE_HOME:=${HOME}/.cache}" + +export HOME +export OTTO_HOME +export OTTO_LISTEN +export OTTO_WEB_UI_ENABLED +export OTTO_LOG_LEVEL +export OTTO_LOG_FORMAT +export CLAUDE_CONFIG_DIR +export CODEX_HOME +export XDG_CONFIG_HOME +export XDG_DATA_HOME +export XDG_STATE_HOME +export XDG_CACHE_HOME + +ensure_dir() { + local dir="$1" + mkdir -p "$dir" + if [[ "$(id -u)" == "0" ]]; then + local owner + owner="$(stat -c "%u" "$dir")" + if [[ "$owner" == "0" ]]; then + chown otto:otto "$dir" + fi + fi +} + +ensure_dir "$HOME" +ensure_dir "$OTTO_HOME" +ensure_dir "$CLAUDE_CONFIG_DIR" +ensure_dir "$CODEX_HOME" +ensure_dir "$XDG_CONFIG_HOME" +ensure_dir "$XDG_DATA_HOME" +ensure_dir "$XDG_STATE_HOME" +ensure_dir "$XDG_CACHE_HOME" + +if [[ "$#" -gt 0 ]]; then + if [[ "$(id -u)" == "0" ]]; then + exec gosu otto "$@" + fi + exec "$@" +fi + +if [[ -z "${OTTO_PASSWORD:-}" ]]; then + { + echo "[otto] WARNING: OTTO_PASSWORD is not set." + echo "[otto] The daemon accepts unauthenticated control connections from any client that can reach it." + echo "[otto] Set OTTO_PASSWORD for any published port or network-reachable deployment." + } >&2 +fi + +if [[ ! -f /etc/otto-server-entry ]]; then + echo "[otto] FATAL: /etc/otto-server-entry is missing." >&2 + exit 1 +fi + +entry="$(cat /etc/otto-server-entry)" +echo "[otto] starting daemon on ${OTTO_LISTEN} with web UI ${OTTO_WEB_UI_ENABLED}" +if [[ "$(id -u)" == "0" ]]; then + exec gosu otto node "$entry" +fi +exec node "$entry" diff --git a/docker/docker-compose.example.yml b/docker/docker-compose.example.yml new file mode 100644 index 000000000..5ba6cd07e --- /dev/null +++ b/docker/docker-compose.example.yml @@ -0,0 +1,21 @@ +# Minimal Otto daemon + web UI deployment. +# +# Open http://localhost:6868 after `docker compose up -d`. +# For any network-reachable deployment, change OTTO_PASSWORD first. +services: + otto: + image: ghcr.io/draek2077/otto:latest + container_name: otto + restart: unless-stopped + ports: + - "6868:6868" + environment: + OTTO_PASSWORD: "change-me" + # Add DNS names you use to reach this container. IPs and localhost are + # already allowed by default. + # OTTO_HOSTNAMES: "otto.example.com,.lan" + volumes: + # Persistent daemon state and agent credentials/config. + - ./otto-home:/home/otto + # Code visible to Otto and the agents it launches. + - ./workspace:/workspace diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..420e43ef6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,174 @@ +# Otto software documentation + +**This is the official documentation for how Otto works.** Durable, evergreen architectural facts — +system design, subsystem behaviour, conventions and the gotchas you cannot derive from the code. + +It is the **specification we build against**. When the code and a page here disagree, that is a +defect in one of them, not a matter of taste. + +| Tree | Holds | Tense | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------- | +| **`docs/`** (here) | How Otto works | Present — _this is how it behaves_ | +| [`projects/`](../projects/README.md) | Charters for work not yet done, and the single open-work ledger | Future — _this is what we will build_ | +| [`findings/`](../findings/README.md) | Dated investigation reports: what was measured, how, and what it ruled out | Past — _this is what we measured_ | +| [`CLAUDE.md`](../CLAUDE.md) | Working rules for AI agents in this repo | Imperative — _do this, never that_ | +| [`archdocs/`](../archdocs/README.md) | The system-level architecture record: AsciiDoc pages with Mermaid diagrams, one level above this tree | Present, wide-angle | + +`CLAUDE.md` is deliberately **not** a documentation index. It is agent context — rules, gates and +constraints. This file is the index. + +## What belongs here + +A page earns its place in `docs/` when its content will still be true after the current work ships. +Point-in-time plans, build sequencing, findings reports and status belong in +[`projects/`](../projects/README.md). Code-level facts belong in comments next to the code. + +**When you learn something meta** — a gotcha, a convention, a workflow, a piece of system context +that will outlive the current task — update the relevant page here or add one, and list it below. +An unlisted page is an invisible page. + +--- + +## Contents + +- [Start here](#start-here) +- [Chat, agents and orchestration](#chat-agents-and-orchestration) +- [Workspaces, files and git](#workspaces-files-and-git) +- [Editor and code intelligence](#editor-and-code-intelligence) +- [Providers and integration](#providers-and-integration) +- [Client and UI](#client-and-ui) +- [Protocol, data and performance](#protocol-data-and-performance) +- [Testing](#testing) +- [Build, release and operations](#build-release-and-operations) +- [Reference](#reference) + +--- + +## Start here + +| Page | What's in it | +| ------------------------------------------ | ------------------------------------------------------------------------------- | +| [product.md](product.md) | What Otto is, who it's for, where it's going | +| [architecture.md](architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow | +| [glossary.md](glossary.md) | Authoritative terminology — the UI label wins, no synonyms | +| [coding-standards.md](coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization | +| [development.md](development.md) | Dev server, build sync gotchas, CLI reference, agent state | + +## Chat, agents and orchestration + +| Page | What's in it | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [chat-lifecycle.md](chat-lifecycle.md) | Chat states, delivery (Interrupt vs Queue for a busy agent), parent/child relationships, archive **and delete** semantics (delete removes Otto's record only — provider transcripts stay), tabs vs archive, the subagents track (including **the observed-id rule: `parent::sub::key` has no `ManagedAgent`, so fetch, stop _and archive_ each need it special-cased**), the Background Tasks track, **the never-destroy-typed-text rule for the composer**. **A chat is the wrapper; an agent is the AI session inside it** | +| [attachment-lifecycle.md](attachment-lifecycle.md) | Where agent-produced image bytes live and who deletes them — the three tiers (materialized record under `$OTTO_HOME/attachments`, preview cache, sent attachments), the retention policy, and why a preview attachment must be pinned or the GC eats it | +| [agent-personalities.md](agent-personalities.md) | Agent Personalities — data model, the 7 roles, spawn-snapshot lifecycle, the starter team, **personality memory** (accrued lessons, injection, transfer on delete) | +| [agent-teams.md](agent-teams.md) | Agent Teams — per-host templates of personalities, the active team, the instant switcher | +| [message-playback.md](message-playback.md) | Reading replies aloud — when the per-bubble play button appears, the auto-speech queue and its interruption rules, why dedupe is text-keyed, why punctuation pauses are synthesized as PCM silence rather than left to the model, and the three audio channels (spoken replies / voice cues / Visualizer) that never mix | +| [subagent-accounting.md](subagent-accounting.md) | Real per-subagent token and cost accounting — provider-neutral core, the 3-step adapter contract, parent-residual de-inflation, the pricing invariant. Claude is the reference | +| [orchestration-node-capabilities.md](orchestration-node-capabilities.md) | What a Graph node can declare — output fields and `submit_output`, conditional edges, per-node tool authority and query tools, retry and time limits, prompt templates, the three-valued node result | +| [safe-unattended.md](safe-unattended.md) | Safe unattended runs — the deny-by-default posture, `dontAsk` mode, Auto/Haiku coercion, the deny-responder | +| [suggested-tasks.md](suggested-tasks.md) | Suggested Tasks — the `spawn_task` / `dismiss_task` contract, trigger-first descriptions, the auto-approval rationale, the 4 start modes and the `detached` flag | +| [timeline-sync.md](timeline-sync.md) | Agent chat delivery paths — live events versus timeline backfill | +| [visualizer.md](visualizer.md) | Visualizer — the vendored agent-flow render layer, the bridge contract, the Otto event adapter | +| [activity-stats.md](activity-stats.md) | Activity Stats — the daemon-wide usage counter store, day-bucketed rollups, **retention** (35-day buckets, 30-day ledger, all-time totals never trimmed), chokepoints, the `stats.activity.get` RPC, the itemized usage log | +| [terminal-activity.md](terminal-activity.md) | Terminal activity indicators — the source-agnostic tracker, agent hook reporting, adding a new hook provider | +| [context-management.md](context-management.md) | Everything sent before you type — hard vs soft edges, the six-category inventory, % of context window as the severity unit, the three-pane tab, the Always load ↔ Link only control, deterministic findings | + +## Workspaces, files and git + +| Page | What's in it | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [workspace-lifecycle.md](workspace-lifecycle.md) | Workspace and worktree lifecycle — archive, branch cleanup (detect → ask → act), re-attach, the immutable `cwd`, workspace activity buckets. Separate from chats by design | +| [new-project.md](new-project.md) | The New project page — `project.scaffold` as one daemon-owned transaction, the step plan, partial-failure reporting, folder-name validation, remote creation per provider | +| [changes-view.md](changes-view.md) | What the Changes diff is measured against — the base-resolution ladder, the local-vs-origin fork-point rule, the per-worktree base override for stacked branches | +| [git-providers.md](git-providers.md) | Git hosting — the provider-neutral forge layer (GitHub + Bitbucket Cloud) | +| [git-file-history.md](git-file-history.md) | Per-file git investigation — history/diff/blame/origin over local git; `--follow`, paged blame, no forge and no per-provider rollout | + +## Editor and code intelligence + +| Page | What's in it | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [text-editor.md](text-editor.md) | IDE-grade text editing — daemon file RPCs, the CM6 editor, AI Refactor, the unified file tab, the File Editor shortcut scope | +| [refine.md](refine.md) | The reviewed AI rewrite — the propose-then-accept invariant, the job tab, the document/reference working set, and **why it is prose-only** | +| [file-mutations.md](file-mutations.md) | Create/delete/rename — why delete is a permanent unlink and not the trash, the opt-in `recursive` and no-overwrite rules, and the parent-resolving path guard that never follows the final component | +| [code-intelligence.md](code-intelligence.md) | The LSP client — go-to-definition, hover, references, rename, diagnostics; the pool's lifecycle obligation, the indexing-cost policy, language rows, why linters are language servers too | +| [solution-view.md](solution-view.md) | The Solution lens — the tree as the build system sees it (.NET first); why LSP gives no project structure, the sidecar, "disabled does no work", the out-of-workspace policy | +| [markdown-rendering.md](markdown-rendering.md) | The shared markdown pipeline — we translate embedded HTML rather than render it, `remoteImages` decides which surfaces may fetch, and a document's own relative images resolve under the workspace root | +| [widgets.md](widgets.md) | Inline widgets — the `show_widget` tool, why the payload rides in `metadata`, the three sandboxes and the `sendPrompt` privilege, and why there is no network at all | +| [file-icons.md](file-icons.md) | Material icon theme integration for the file explorer | + +## Providers and integration + +| Page | What's in it | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [providers.md](providers.md) | Adding a new agent provider end to end | +| [custom-providers.md](custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | +| [preview.md](preview.md) | The Preview subsystem — design principles (token economy, guardrail-bearing tool descriptions, daemon-enforced tab binding), settings, `launch.json`. **Read before touching anything preview-related** | +| [service-proxy.md](service-proxy.md) | Exposing workspace scripts at public URLs — DNS setup, reverse proxy config | + +## Client and UI + +| Page | What's in it | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [design.md](design.md) | Theme tokens — colors, fonts, spacing, radii, icons | +| [unistyles.md](unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, and the alternatives in order | +| [hover.md](hover.md) | Hover — the canonical pattern (plain View + pointer handlers, separate inner Pressable) and the three ways agents break it | +| [floating-panels.md](floating-panels.md) | Anchored popovers — the Portal/Modal escape for Android, lifecycle gates, the keyboard shared value, the status-bar offset, the flash | +| [mobile-panels.md](mobile-panels.md) | Compact layout's three mutually exclusive destinations as **one** interaction — position ownership, ordering and interruption, integration rules | +| [expo-router.md](expo-router.md) | Route ownership, startup restore, native blank-screen gotchas. **Read before changing routes, startup routing, remembered-workspace restore or active-workspace selection** | +| [forms.md](forms.md) | Form architecture — the non-React form model, the form kit, load-state gating. The schedule form is the golden example | +| [ui-icons.md](ui-icons.md) | General UI icons — Material Symbols vendoring, the codegen script, how to add or change an icon | +| [text-effects.md](text-effects.md) | The "working" text effect — sweep vs glyph primitives, the theme registry, the shipped themes | +| [i18n.md](i18n.md) | Client UI translations in `packages/app/src/i18n` | +| [feature-flags.md](feature-flags.md) | Gated features — turning a subsystem off so its code never loads; the Metro no-tree-shake constraint, the lazy-split pattern, Visualizer as the reference | +| [feedback.md](feedback.md) | In-app **Send feedback** — client posts straight to the otto-code.me intake (not via the daemon), anonymous by default, the closed context list, the Worker's spam bounds | + +## Protocol, data and performance + +| Page | What's in it | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| [rpc-namespacing.md](rpc-namespacing.md) | WebSocket RPC naming — dotted namespaces and `.request`/`.response` pairs | +| [protocol-validation.md](protocol-validation.md) | zod-aot generated inbound WebSocket validation, the patched compiler regressions, schema-purity rules | +| [data-model.md](data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations | +| [terminal-performance.md](terminal-performance.md) | Terminal latency pipeline, coalescing and backpressure invariants, benchmark and perf-spec usage | +| [client-performance.md](client-performance.md) | The app's self-measurement: frame timing, retained-state census, daemon-traffic accounting, the soak | + +## Testing + +| Page | What's in it | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [testing.md](testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization, **the three app E2E tiers** (mock / local-AI / real) and the coverage matrix | +| [mobile-testing.md](mobile-testing.md) | Maestro and mobile test workflows | +| [ad-hoc-daemon-testing.md](ad-hoc-daemon-testing.md) | The isolated in-process daemon test harness | +| [browser-capture-harness.md](browser-capture-harness.md) | The real-Electron browser screenshot harness and the compositor-surface gotcha | + +## Build, release and operations + +| Page | What's in it | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| [release.md](release.md) | Release playbook, draft releases, the completion checklist | +| [fork-release-guide.md](fork-release-guide.md) | This fork's release infrastructure versus upstream — EAS/Play/Cloudflare/domain gaps | +| [upstream-merges.md](upstream-merges.md) | Ingesting upstream Paseo changes — remote setup, rebrand tooling, the merge playbook, merge-at-a-tag cadence, the intent ledger of what we skipped | +| [android.md](android.md) | App variants, local and cloud builds, EAS workflows | +| [desktop-linux.md](desktop-linux.md) | Linux desktop — packaging (deb/rpm/AppImage), the sandbox profile, GPU fallback, diagnostics | +| [docker.md](docker.md) | Running the daemon and bundled web UI in Docker — volumes, agent images, security | +| [site-demos.md](site-demos.md) | The marketing-site capture pipeline — one run/one feature, the whole-frame rule, the two lanes, resolution and zoom, isolation, the gotchas ledger | + +## Reference + +| Page | What's in it | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [references.md](references.md) | **The references and sources index** — every external source Otto drew on, grouped by what it informed, with why we used it and what it contributed. Includes "considered and rejected" entries, and an ordered reading list for the agentic-coding-templates initiative | +| [glossary.md](glossary.md) | Authoritative terminology | +| [writing-style.md](writing-style.md) | House prose rules for everything a reader outside this repo sees — **no em-dashes**, what to use instead, and the first-person voice. Site, manual, release notes, marketing drafts | +| [opencode-global-event-baseline.md](opencode-global-event-baseline.md) | OpenCode global event verification baseline — a dated snapshot, kept as evidence rather than as a live spec | + +--- + +## Related, outside this tree + +- [`../SECURITY.md`](../SECURITY.md) — relay threat model, E2E encryption, DNS rebinding, agent auth +- [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — contribution workflow +- [`../CHANGELOG.md`](../CHANGELOG.md) — release history +- [`../archdocs/`](../archdocs/README.md) — the architecture site (`npm run archdocs:serve`, port 4400) +- `../public-docs/` — the **user-facing manual** published to otto-code.me/docs. A different audience + and a different contract from this tree; it documents what Otto does, not how it is built diff --git a/docs/activity-stats.md b/docs/activity-stats.md new file mode 100644 index 000000000..824426fae --- /dev/null +++ b/docs/activity-stats.md @@ -0,0 +1,108 @@ +# Activity Stats + +A lightweight "how much has Otto done" dashboard: daemon-tracked lifetime usage counters surfaced as preset time-range rollups, plus an app setting for which screen the app opens to. Deliberately **not** a telemetry/analytics product — no session tracking, no charts, no external reporting; a small local counter store for the user's own curiosity. Gated behind `server_info.features.activityStats` (`COMPAT(activityStats)`, added in v0.5.3). + +## The counter store + +`ActivityStatsStore` (`packages/server/src/server/activity-stats/activity-stats-store.ts`) follows the "tiny file-backed daemon-wide counter" pattern — cf. `PushTokenStore` (`push/token-store.ts`) and `PersonalityStatsStore` (`agent/personality-stats-store.ts`). Persisted at `$OTTO_HOME/activity-stats.json`, atomic writes via `writeJsonFileAtomic`, with a serialized in-memory queue so concurrent increments never lose counts. + +Stats are bucketed by **calendar day** (`YYYY-MM-DD`, local daemon date) plus running all-time totals — no session start/end lifecycle and no crash-recovery bookkeeping. The day bucket is decided at increment time, so counting survives daemon restarts, multiple concurrent clients, and the phone app backgrounding/foregrounding. + +```ts +interface ActivityCounters { + // Activity tallies (cost-free counts) + messagesSent: number; + messagesReceived: number; + tokensSent: number; + tokensReceived: number; + agentsCreated: number; + runsOrchestrated: number; + subagentsInvoked: number; + backgroundTasksInvoked: number; + thoughts: number; + toolsCalled: number; + artifactsCreated: number; + schedulesExecuted: number; + // Usage & cost, by category. Cost is an integer count of micro-USD (usd × 1e6) + // so it stays summable; it is only populated for turns a provider prices. + costMicroUsd: number; + mainChatTokensIn: number; + mainChatTokensOut: number; + mainChatCostMicroUsd: number; + generationsTokensIn: number; + generationsTokensOut: number; + generationsCostMicroUsd: number; + subagentTokensIn: number; + subagentTokensOut: number; + subagentCostMicroUsd: number; + compactionTokensIn: number; + compactionTokensOut: number; + claudeTokensIn: number; + claudeTokensOut: number; +} + +interface ActivityStatsFile { + version: 1; + allTime: ActivityCounters; + daily: Record; // "YYYY-MM-DD", local date +} +``` + +Each of the 26 counters is an individually optional, additive leaf on both the stored JSON and the protocol schema, so new counters can be added — or existing ones dropped from the UI — in later passes without a migration or a breaking change. `increment(field, by = 1)` bumps both `allTime[field]` and today's bucket, then persists; `getRollups()` sums the map into the preset windows on read. The category leaves are gated on the wire by `features.usageCostCategories` — an old daemon leaves them all at 0, and the client hides the grid rather than presenting zeros as accounting. + +### Retention + +Two stores, two windows, one principle: **detail is a bounded window, cumulative totals are forever.** + +| Store | Rule | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `daily` day buckets | `DAILY_RETENTION_DAYS = 35` — buckets older than the window are dropped, and the map is additionally capped at 35 buckets | +| `allTime` counters | **Never trimmed.** They are the cumulative record and must outlive every trimmed bucket | +| `UsageLogStore` ledger rows | 30-day age window, **no row cap** — every row inside 30 days is kept, however many there are. Test-locked (`usage-log-store.test.ts`) | + +Retention applies **at rest, not only on write**, in both stores. Trimming only inside the write path meant a daemon that booted and then recorded nothing kept serving data from outside its own window — the window silently stopped existing the moment the machine went quiet. So `load()` trims in both: the ledger persists the trim, the counter store corrects its in-memory view and lets the next `increment()` persist it. + +The bucket trim is **age-first, count-second**. Counting buckets alone was not retention: a daemon idle for months kept its last 35 _non-empty_ day keys even if all of them were a year old. `getRollups()` never read them, so nothing was wrong on screen — but "retained forever because nothing happened" is the opposite of a window. The count pass stays as the backstop for keys the age pass cannot judge, i.e. future-dated buckets from clock skew. + +Neither window is configurable, and neither ever deletes an agent record — chat records have their own, separate story in [chat-lifecycle.md](chat-lifecycle.md#delete). + +## The usage log + +Sibling store to the counters: `UsageLogStore` (`packages/server/src/server/activity-stats/usage-log-store.ts`), mirroring `ActivityStatsStore`'s serialized-queue + atomic-write + coalesce shape. It backs the **Log** tab on the Metrics screen (Summary | Log) — the itemized rows behind the tiles, so a user can scroll every token-costing activity with its model and cost rather than only reading aggregates. Gated behind `server_info.features.usageLog` (`COMPAT(usageLog)`, added in v0.6.4). + +**One event, two sinks.** At the existing `AgentManager.recordUsageActivity` chokepoint the daemon builds one `UsageEvent`, then (1) appends it to the capped log and (2) folds it into the day-bucketed counters above. The tiles are therefore the log's rollup **by construction** — one source event, so the two surfaces cannot structurally disagree. The dual-write is **best-effort, with no transactional coupling** between the two files: if they drift by a row, that is accepted rather than defended against. + +**Why the counters stay their own durable store** (rather than being recomputed from the log on read): the log is rotated to a **30-day age window**, while the cumulative "All Time" tiles must outlive trimmed rows. Row = bounded detail; counter = cumulative summary, persisted forever as tiny running totals. Retention is the age window only — there is no row cap, so every row inside 30 days is kept. See [Retention](#retention) above for where the window is enforced. + +Appends are coalesced onto a ~2s timer whose handle is `unref()`'d, so the daemon's shutdown path calls `usageLogStore.flush()` alongside `agentStorage.flush()`. Without it every daemon exit silently dropped up to one coalesce window of rows. + +**What earns a row:** only the token/cost-_bearing_ activities — chat turns, sub-agent turns, and generations (bare completions: titles, names, commit/PR messages, summaries). Compaction is a slice _within_ a turn's usage (openai-compat folds it into the turn's reported usage, so billing it separately would double-count), so it rides its turn's row as a sub-figure rather than standing as its own row. The pure tallies — `messagesSent`, `agentsCreated`, `toolsCalled`, `artifactsCreated`, `schedulesExecuted` — are cost-free counts and are never log rows. + +On the wire, `UsageEvent` `kind`/`provider` are `z.string()` rather than enums so an old client still parses kinds a newer daemon invents. The log reuses the existing `activity_stats_changed` push for live refresh instead of adding its own notification, since it writes at the same chokepoint that moves the counters. + +## Chokepoints + +`ActivityStatsStore` is instantiated once in `bootstrap.ts` and threaded into `AgentManager`, `RunService`, `ScheduleService`, and `ArtifactService`, which call `increment(field)` at: + +| Counter | Chokepoint | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agentsCreated` | `AgentManager.createAgent` → `createAgentInternal` | +| `subagentsInvoked` | Same `createAgent` path when `relationship.kind === "subagent"`, plus `appendObservedSubagentTaskEvent` on `task_started` (observed Claude subagents) | +| `backgroundTasksInvoked` | The `background: true` resolution point in `agent/tools/otto-tools.ts` (create_agent / send_message handlers) | +| `runsOrchestrated` | `RunService.startRun` | +| `schedulesExecuted` | `ScheduleService.runSchedule` | +| `artifactsCreated` | `ArtifactService.create` | +| `messagesSent` / `messagesReceived` / `thoughts` / `toolsCalled` | `AgentManager.recordAndDispatchTimelineItem` — switch on `item.type` | +| `tokensSent` / `tokensReceived` | `AgentManager.onStreamTurnCompleted` — same per-turn `event.usage` already summed by `accumulateAgentTokens` | + +## Protocol & client + +RPC pair `stats.activity.get.request` / `stats.activity.get.response` (per [rpc-namespacing.md](rpc-namespacing.md), modeled on `provider.usage.list`). The client never sees raw daily buckets — the daemon computes five preset rollup windows on request: **Today, Yesterday, Last 7 Days, Last 30 Days, All Time** — a fixed-shape payload with no date math on the client. The client queries on focus + manual refresh (`use-activity-stats.ts`), rendering a stat-tile grid at the `/stats` route (`stats-screen.tsx`), reached from a `Sparkles` button in the sidebar footer. + +**Reset:** the `stats.activity.reset.request`/`.response` RPC pair wipes both usage sinks in one shot — `ActivityStatsStore.reset()` (all-time totals + every day bucket) and the sibling `UsageLogStore.reset()` (the itemized ledger behind the Log tab) — so the tiles and rows start fresh together. Wired in `bootstrap.ts` as a single callback threaded through `websocket-server` → `session` alongside `getActivityRollups`. Gated behind `server_info.features.statsReset` (`COMPAT(statsReset)`, added in v0.6.4): the app's Metrics screen only shows its **Reset** button (behind a destructive confirm dialog) when the daemon advertises the capability, so an old daemon with no handler never receives a request that would hang. `ActivityStatsStore.reset()` fires the coalesced change notification, so the same `activity_stats_changed` ping re-syncs every client (the client also invalidates both queries locally for an instant wipe). + +**Live updates:** the daemon also broadcasts a payload-free `activity_stats_changed` notification whenever any counter moves, coalesced in `ActivityStatsStore` (`onDidChange`, max one per ~2s) so bursts stay quiet. The client (session-context) invalidates the stats query on that ping — a focused Metrics screen refetches immediately (consistent with the invalidation-only react-query convention), an unmounted one just goes stale. Tiles flash a brief accent highlight when their displayed value changes. Purely additive: old clients drop the unknown message type, old daemons never send it (screen degrades to focus/manual refresh), so it rides the existing `activityStats` capability with no new feature flag. + +## App start screen + +`appStartScreen: "dashboard" | "home" | "workspaces"` on `AppSettings` (`use-settings/storage.ts`, default `"workspaces"` — today's restore behavior, unchanged) chooses the app's landing screen, branched in `resolveHostIndexRoute` (`host-runtime-bootstrap.ts`) after the existing restore computation: `dashboard` → `/stats`, `home` → `/open-project`, `workspaces` → restore. This keeps the `/` → `/h/[serverId]` → leaf two-hop shape from [expo-router.md](expo-router.md) intact. Configured via a `SegmentedControl` row in the General settings section. diff --git a/docs/ad-hoc-daemon-testing.md b/docs/ad-hoc-daemon-testing.md new file mode 100644 index 000000000..b96f07f54 --- /dev/null +++ b/docs/ad-hoc-daemon-testing.md @@ -0,0 +1,165 @@ +# Ad-hoc daemon testing + +Spin up an isolated in-process daemon test harness without touching the main daemon on port 6868. + +This is for test code only. Executable daemon processes must start through +`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`; +do not use `createOttoDaemon` as a product launch path. + +## Quick start + +```typescript +import os from "node:os"; +import path from "node:path"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import pino from "pino"; +import { createOttoDaemon } from "./bootstrap.js"; +import { DaemonClient } from "./test-utils/daemon-client.js"; + +const logger = pino({ level: "warn" }); +const ottoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "otto-test-")); +const ottoHome = path.join(ottoHomeRoot, ".otto"); +await mkdir(ottoHome, { recursive: true }); +const staticDir = await mkdtemp(path.join(os.tmpdir(), "otto-static-")); + +const daemon = await createOttoDaemon( + { + listen: "127.0.0.1:0", // OS picks a free port + ottoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: {}, + agentStoragePath: path.join(ottoHome, "agents"), + relayEnabled: false, + relayEndpoint: "relay.otto-code.me:443", + appBaseUrl: "https://app.otto-code.me", + // Add custom config here, e.g.: + // providerOverrides: { ... }, + }, + logger, +); + +await daemon.start(); +const target = daemon.getListenTarget(); +const port = target!.type === "tcp" ? target!.port : null; + +const client = new DaemonClient({ + url: `ws://127.0.0.1:${port}/ws`, + appVersion: "0.1.70", // see gotcha #1 +}); +await client.connect(); +await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + +// ... do your testing ... + +await client.close(); +await daemon.stop(); +await rm(ottoHomeRoot, { recursive: true, force: true }); +await rm(staticDir, { recursive: true, force: true }); +``` + +Run with: + +```bash +npx tsx packages/server/src/server/your-script.ts +``` + +## Using the test helper + +For simpler cases, `createTestOttoDaemon` + `DaemonClient` handles temp dirs and port selection: + +```typescript +import { createTestOttoDaemon } from "./test-utils/otto-daemon.js"; +import { DaemonClient } from "./test-utils/daemon-client.js"; + +const daemon = await createTestOttoDaemon(); +const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.70", +}); +await client.connect(); +await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + +// ... test ... + +await client.close(); +await daemon.close(); // stops daemon + cleans up temp dirs +``` + +The test helper does **not** expose `providerOverrides`. In test harnesses, use `createOttoDaemon` directly when you need it (see quick start above). + +## Common client methods + +```typescript +// Provider discovery +const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" }); +const models = await client.listProviderModels("claude"); +const modes = await client.listProviderModes("claude"); + +// Agent lifecycle +const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" }); +await client.sendMessage(agent.id, "Hello"); +const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle"); +``` + +## Gotchas + +### 1. appVersion gates provider visibility + +The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses. + +Always pass `appVersion`: + +```typescript +const client = new DaemonClient({ + url: `ws://127.0.0.1:${port}/ws`, + appVersion: "0.1.70", +}); +``` + +### 2. Provider snapshots are async + +After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading: + +```typescript +let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" }); +for (let i = 0; i < 20; i++) { + const entry = snapshot.entries.find((e) => e.provider === "gemini"); + if (entry && entry.status !== "loading") break; + await new Promise((r) => setTimeout(r, 2_000)); + snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" }); +} +``` + +### 3. fetchAgents is required before most operations + +Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang. + +### 4. listen: "127.0.0.1:0" for port allocation + +Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs. + +### 5. Script must live inside packages/server + +The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors. + +### 6. Cleanup on failure + +Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails: + +```typescript +try { + // ... test logic ... +} finally { + await client.close(); + await daemon.stop().catch(() => undefined); + await rm(ottoHomeRoot, { recursive: true, force: true }); +} +``` + +### 7. ACP providers spawn real processes + +When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider. diff --git a/docs/agent-personalities.md b/docs/agent-personalities.md new file mode 100644 index 000000000..0e184bc96 --- /dev/null +++ b/docs/agent-personalities.md @@ -0,0 +1,344 @@ +# Agent Personalities + +An **agent personality** (UI label: "Agent personalities", Host settings → Agents) is a named, reusable agent template stored per-host in the daemon config. It is the primary way a user picks "who does the work": instead of choosing a raw provider + model + effort + mode every time, they pick a personality once and it fills all of that in — on a local LM Studio model just as much as a frontier API. This is the provider-agnostic leveling-up pattern from [CLAUDE.md](../CLAUDE.md): a personality bound to a local model is as capable a Chatter or Judger as one bound to a hosted API. + +Personalities shipped in 0.5.0. This doc is the durable architecture. The one open product decision — persisting a client schedule-form personality binding — is tracked in [the projects ledger](../projects/README.md#onboarding--ux). + +## What a personality binds + +Each personality bundles a **brain** and an **identity**: + +- **provider → model** pair, +- a **canonical effort level** (`off`…`max`), resolved to the model's nearest advertised option at spawn (see [glossary Effort](glossary.md), `packages/protocol/src/effort.ts`), +- a **default permission mode** (provider-scoped), +- a **personality prompt** (fills the per-agent `systemPrompt`), +- a **`respectGlobalAppendPrompt`** toggle (whether the daemon-wide `appendSystemPrompt` still stacks on top; `false` = the personality prompt stands alone), +- one or more **roles**, +- an **identity**: a name, two spinner colors, and an optional **TTS voice** (spoken identity). + +## Data model + +`agentPersonalities` is a section on `MutableDaemonConfig` (`packages/protocol/src/messages.ts`, alongside `MutableDaemonConfigSchema` / its patch variant, both `.passthrough()`). It persists through `daemon-config-store.ts`'s merge whitelist and hot-reloads over `status:daemon_config_changed`. The capability flag is `features.agentPersonalities` on `ServerInfoStatusPayloadSchema.features` — tagged `COMPAT(agentPersonalities)`. + +``` +AgentPersonality { + id: string // stable, machine-generated; the ONLY thing references bind to + name: string // human label, freely renamable, unique per host + provider: string // provider id (e.g. "codex", "openai-compat") + model: string // provider-scoped model id + effortLevel: EffortLevel // canonical: off|minimal|low|medium|high|xhigh|max + modeId: string // default permission mode (provider-scoped) + personalityPrompt?: string // → per-agent systemPrompt + respectGlobalAppendPrompt: boolean // default true + roles: PersonalityRole[] // one or more + spinner: { glowA: string; glowB: string } // two hex colors for BlobLoader + voice?: { provider: string; model: string; name: string } // TTS voice; soft binding + voiceCues?: { join?: string[]; thinking?: string[]; waiting?: string[]; done?: string[] } // Visualizer spoken cues + memoryEnabled?: boolean // accrues lessons across sessions; ABSENT MEANS ON (see Memory) +} +``` + +`voiceCues` are the pre-generated (and hand-editable) short lines an agent **speaks** in the personality's voice at four moments — it joins, first starts thinking, waits on still-running sub-agents, completes a turn. Stored on the personality so they're deterministic and tunable (playback reads them directly, no runtime generation). Authored in the editor's Voice tab (Generate button + auto-generate on save when empty); see [Voice cues](#voice-cues) below. + +This is the logical shape; on the wire everything past `provider`/`model` is an optional plain string (`AgentPersonalitySchema`, `messages.ts` — no enums) for forward compat, and the daemon validates values against its own catalog when applying a patch. + +Two invariants: + +- **Identity is the `id`, never the `name`.** Renaming a personality must not break any schedule, remembered picker selection, or in-flight agent. Everything binds `id`. +- **Effort is stored canonical, resolved at spawn.** Store the `EffortLevel`, never a raw `thinkingOptionId` (option ids differ per model). Resolve against the bound model at spawn with `resolveEffortOption` (`packages/protocol/src/effort.ts`; `packages/server/src/server/agent/effort-levels.ts` re-exports). + +## Roles (8) + +A personality carries one or more roles. Roles gate where a personality shows up. A new personality defaults to **all roles**; the editor has an All / None toggle. + +| Role | Consumed by | App picker surface today | +| ---------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| **Chatter** | Interactive agent chats | Composer agent-controls picker | +| **Artificer** | Creating & managing artifacts | Artifact create sheet | +| **Scheduler** | Creating & managing schedules | Schedule form sheet | +| **Writer** | Fast small-text generation — commit messages, PR text, branch/workspace names (mini-tasks) | None (daemon-internal, see below) | +| **Coder** | Spawned as a coding sub-agent (incl. text-editor AI refactor) | None yet — via skills / MCP | +| **Judger** | Judging / review passes | None yet — via committee / review skills | +| **Advisor** | Planning / second opinion; **read-only / advisory** | None yet — via advisor / committee skills | +| **Orchestrator** | Drives multi-agent workflows. **Semantic label only** — enumerating & spawning personalities is open to every agent | None yet — via committee / panels / handoff / loop | + +The role catalog lives in `PERSONALITY_ROLES` and the shared predicate/helpers in `packages/protocol/src/agent-personalities.ts` (used by both app pickers and the daemon). + +### Role tiers: coordinators vs focused workers + +Roles fall into two behavioral **tiers** (`PERSONALITY_ROLE_INFO` in `agent-personalities.ts`): + +- **Coordinators** — **Chatter, Artificer, Scheduler, Advisor, Orchestrator.** They converse, plan, and delegate; they're expected to enumerate the roster and launch other agents/personalities to get work done. +- **Focused workers** — **Writer, Coder, Judger.** They lift a single thing someone is waiting on and should stay on task, not fan out into sub-agents. + +A personality that carries **any** coordinator role is a coordinator (`personalityCanLaunch` — a `chatter + coder` both codes and delegates); one whose roles are entirely focused (or roleless) is a focused worker. + +**This is guidance, not a gate.** Every agent keeps the same tools — `list_personalities` and personality-named spawns are open to all (that's the "see and understand each other" property). The tier only drives two in-context nudges: + +- **A spawn-time role directive** (`composeRoleFocusDirective`) folded into the personality's system prompt at spawn: coordinators are told "orchestration is yours"; focused workers are told "someone is waiting on this — stay on it, don't spawn sub-agents unless essential." +- **`list_personalities` decision-aid fields** — every entry carries `tier`, `canLaunch`, and a `guidance` "why you'd choose me" blurb (joined from its roles' taglines), so a deciding agent self-selects the right teammate from the list alone. + +**Writer and Coder replaced the old single `Worker` role.** Worker split into the fast small-text tier (`writer`) and the coding sub-agent tier (`coder`). A personality persisted with the retired `worker` tag resolves to `coder` via `LEGACY_ROLE_ALIASES` in `agent-personalities.ts` — normalization maps it before filtering, so no personality silently loses its role. Roles still ride the wire as plain strings, so old peers keep parsing. + +### Writer routing (mini-tasks prefer a personality first) + +Daemon-internal mini-tasks — commit messages, PR title/body, and branch/workspace name generation — resolve their model through `resolveStructuredGenerationProviders` (`packages/server/src/server/agent/structured-generation-providers.ts`). When a caller passes `role: "writer"`, every **available** Writer personality (checked against the live provider snapshot with the same `checkPersonalityAvailability` predicate the pickers use) is resolved to a concrete provider/model/effort and **prepended ahead of the legacy chain** (explicit `metadataGeneration.providers` config → built-in substring preference list → current selection → hardcoded string). So a user's Writer personality is the primary worker for these tasks; the legacy substring list is the fallback that only runs when no Writer is available or all of them fail. The personality's canonical effort resolves to the model's nearest advertised thinking option here, exactly as at spawn. The two callers that pass `role: "writer"` are `git-metadata-generator.ts` (commit + PR) and `worktree-branch-name-generator.ts` (titles + branches); the roster reaches the resolver through `StructuredGenerationDaemonConfig.agentPersonalities`. + +## Availability ("out of commission") + +A personality is **available on a given host + workspace (cwd)** only if every bound setting resolves against the live providers snapshot there: the provider is present/enabled/authenticated, the bound model exists in that provider's snapshot, and the bound `modeId` exists among that provider's modes. Availability is evaluated against `provider-snapshot-manager.ts` / `use-providers-snapshot.ts`. + +If any check fails the personality is **out of commission**: + +- **In pickers:** grayed out with a reason ("Blaze — LM Studio not connected"), not selectable. +- **In automation (schedules, spawn-by-name):** a **hard-fail with a visible, named error.** No fallback to another model or provider — this follows the repo's no-fallback-paths rule. A schedule pointed at an out-of-commission personality fails its run loudly rather than silently substituting. + +The voice is the one exception: it is a **soft binding** and never gates availability — an unresolvable voice degrades to the host default at playback. + +A personality that omits `modeId` inherits the provider's default mode — but resolution validates that fallback against the provider's live modes catalog before using it (`resolveFallbackModeId`, `agent-personalities.ts`). A provider's advertised `defaultModeId` can go stale relative to its modes; availability only checks the personality's _own_ `modeId`, so an unvalidated fallback would pass resolution and then throw inside `setMode` at apply time. When the fallback is itself absent from the catalog, resolution drops it and the provider picks its own default. + +## Resolution & lifecycle + +**Spawn snapshots the personality onto the agent.** At spawn the personality resolves to a concrete blob — `ResolvedPersonalitySnapshot` (`packages/server/src/server/agent/agent-personalities.ts`) — stored as `AgentSessionConfig.personalitySnapshot` and persisted via `SERIALIZABLE_CONFIG_SCHEMA`. From then on the agent is frozen to its snapshot. + +- **Editing a personality never mutates an in-flight or observe-only agent.** Running streams and read-only observed agents keep the snapshot they were born with — there is no automatic re-resolution, next-turn or otherwise. The only way an existing agent picks up roster edits is an explicit live switch (below), which re-resolves the personality fresh; re-selecting the same personality via the switcher is how you pull edits into a running chat. +- **New jobs re-resolve.** Any fresh spawn picks up the current (edited) settings. + +**Override semantics.** Selecting a personality in a picker fills the underlying provider/model/effort/mode fields; the user may hand-edit any of them (an explicit per-field override) and **the agent keeps the personality identity** (name, spinner colors, prompt) with the overridden brain layered on. Only an explicit "clear personality" detaches back to a plain provider/model selection. In tooling, the template applies verbatim and a caller may override individual fields **only when explicitly requested** — no heuristic substitution. + +## What the model picker shows (the precedence ladder) + +Every apply-now picker — New Chat, New Workspace, New Artifact, New Schedule — resolves the same five tiers, in this order, and the first one that produces a value wins outright. There is no per-surface variant: a picker whose contents depend on which screen opened it is unpredictable by construction. + +| Tier | Source | Applies | +| ----- | ---------------------------------------------------------- | ---------------------------------- | +| **1** | The **active team's** holder of the surface's role | always, no exceptions | +| **2** | A **personality** carrying the role | always, no exceptions | +| **3** | The device's **last-used model** for the provider | only if 1 and 2 are empty | +| **4** | The provider's **default model** (`isDefault`, else first) | only if 3 is empty | +| **5** | Nothing | only if the provider has no models | + +Within tier 2 the device's `lastPersonalityByRole` decides **which** personality — remembered first if it still carries the role and is available, otherwise the first available holder. Memory picks the personality; it never demotes the tier. Seeing a bare model name in a picker therefore means exactly one thing: **no team and no personality carries that role.** + +Tiers 1–2 live in `useFormRolePersonality`'s default effect (`packages/app/src/provider-selection/role-model-personality.ts`, `autoSelectDefault: "always"`). Tiers 3–5 live in `resolveModelField` (`packages/app/src/provider-selection/resolve-agent-form.ts`). The one surface that opts out is a picker **editing a stored record** (schedule edit, artifact edit): the record's own binding is a tier above all of these, and re-deriving would overwrite it. + +### Which surfaces remember + +Preferences seed the create surfaces and nothing else. + +- **New Chat / New Workspace / New Artifact / New Schedule** — an explicit model, mode, or effort pick writes the device preference; an explicit personality pick writes `lastPersonalityByRole`. +- **Existing chats, and any chat past its first message** — **no writes at all.** Switching a started agent's model retargets that agent and leaves no trace: no model preference, no effort preference, no remembered personality. A mid-chat switch feeding back into the create surfaces is what made New Chat open on a model nobody had chosen for it. + +Two writes are suppressed even on create surfaces, both for the same reason — a value that arrived from a higher tier must never be written into a lower one, or the tier it outranks gets overwritten with the winner and reads back next open as the user's own choice: + +- **Personality and team applies don't persist.** They go through `applyPersonalityValues` (`use-agent-form-state.ts`), which sets the form without touching preferences. `setModelFromUser` / `setProviderAndModelFromUser` — real user picks — still do. +- **An auto-resolved tier-2 personality doesn't persist either** (`selectPersonality(id, { persist: false })`). `lastPersonalityByRole` means "what the user chose"; an auto-pick writing itself back would freeze "first available" in place the moment the roster order changed. + +Submitting under a personality is covered by the same rule: `persistFormPreferences` and the schedule form's `persistPreferences` both no-op when a personality or team slot is bound. + +**Overrides are session-only.** Picking a model by hand while a personality is bound clears the personality for that draft (and, on a running agent, after one confirm) but writes nothing — reopen the surface and tier 1/2 reassert. + +### Two personality lists, two press semantics + +Form pickers render the roster twice, and the rows behave differently on purpose (`PersonalityRow`, `combined-model-selector.tsx`): + +- The up-front **Personalities** section is this surface's role-filtered (and team-scoped) shortlist, and the row IS the current binding — so pressing the selected row **clears** the personality. That toggle is the only explicit "clear" affordance the running-agent picker has. +- The **active team / "All personalities"** drill-down is a roster directory over the whole roster, reachable regardless of the surface's role. Its rows are **select-only**: pressing one always binds that personality, including the one already bound. Toggling off here meant a press on a name silently detached the personality and left the raw model as the agent's identity — the opposite of what picking a name means. Clear from the shortlist, or by picking a raw model. + +## Live switch (running agents) + +A RUNNING chat agent can be switched to another personality — or cleared — without losing its conversation. + +**RPC + gate.** `agent.personality.set.request` / `.response` (`packages/protocol/src/messages.ts`): `agentId` + nullable `personalityId` (null = clear). Gated by `server_info.features.setAgentPersonality`, tagged `COMPAT(setAgentPersonality)` — an old daemon simply doesn't advertise the flag and the app hides the switcher entirely; there is no fallback path. + +**Strict resolution.** The session shell (`Session.resolvePersonalitySnapshotForAgent`, `session.ts`) re-resolves the roster id against the agent's cwd before applying. Unlike spawn's soft-skip (`applyPersonalityIdentityToConfig` logs and spawns without identity), the live switch **rejects the RPC** when the personality is unknown or out of commission, surfacing the unavailability reason. It warms only the personality's own provider snapshot so a cold workspace doesn't fan out network probes to every provider. + +**Daemon semantics** (`AgentManager.setAgentPersonality`, `agent-manager.ts`). The switch applies the full personality atomically in one request: + +- **Brain** (model/mode/effort) rides the existing live-session setters (`setModel`/`setMode`/`setThinkingOption`) — applied only when _binding_; **clearing keeps the brain** (model, mode, and effort stay as they are). +- **Prompt** goes through the provider session's optional `applyPersonality` (`AgentSession`, `agent-sdk-types.ts`). Providers that don't implement it (they can't change a system prompt mid-conversation) **reject cleanly** before anything is applied. A personality bound to a different provider than the agent's also rejects. +- **Identity** (name/spinner) follows automatically: the resolved snapshot persists as `config.personalitySnapshot`, and `agent_state` projects `personalityId`/`personalityName`/`personalitySpinner` from it. +- **Serialization:** config mutations on one agent (personality set, model/mode/effort/feature changes) run through a per-agent promise-chain lock in `AgentManager`, so two racing RPCs can't interleave into a mixed half-and-half state. +- **Prompt ownership** mirrors spawn: the personality prompt only owns `config.systemPrompt` when the caller set none at spawn (or it equals the outgoing personality's prompt) — a caller-authored prompt survives switches. `respectGlobalAppendPrompt === false` drops the daemon-global append prompt, same rule as at spawn. + +**Provider differences.** Claude bakes the system prompt into the query options, so `applyPersonality` flags a **lazy query restart**: the change lands on the next turn, resuming the same session id; if a turn is active the RPC returns an "applies next turn" provider notice. The openai-compat provider owns its conversation (`messages[0]` is the system prompt, re-sent every request), so it rebuilds the prompt in place — no restart needed. + +**App flow** (`useRunningChatPersonality`, `packages/app/src/composer/agent-controls/index.tsx`). On a running chat agent, the model picker's provider-family menu pins roster personalities that have the **Chatter** role and match the agent's provider family, filtered by the picker's search box on name. Picking one shows a warning dialog (switches prompt, model, mode, effort; applies next turn) with a "Don't show this again" checkbox persisted as a device-local form preference. While the RPC is in flight the model trigger shows a spinner and the composer locks send/dictation/voice-mode (typing and attachments stay enabled), with a 30-second timeout that re-enables the controls if the daemon doesn't answer. Picking a **raw model** while a personality is bound shows one combined confirm and then clears the personality and applies the model as a single locked flow — the personality detaches and its prompt reverts per the ownership rule. Selection keys on `agent_state.personalityId` (stable across renames), with a `personalityName` match as the fallback against daemons that predate the field; there is no client-side selection state to drift. When the bound personality can't be found in the selectable roster (deleted, renamed on an old daemon, Chatter role removed, or the daemon predates the live switch), the picker synthesizes a display-only entry from `agent_state` so the trigger keeps the truthful name + spinner. + +The RPC shares the per-agent config envelope in `AgentConfigSession` (`packages/server/src/server/session/agent-config/agent-config-session.ts`): success returns any provider notice, failure emits an `activity_log` error frame plus the rejected response. + +## Identity (spinner + voice) + +- **Spinner:** the personality's two colors ride onto the agent's live thinking indicator (`BlobLoader`, `packages/app/src/components/blob-loader.tsx`) via the additive `AgentSnapshotPayload.personalitySpinner` (absent ⇒ theme default). This is the first per-agent color path for the live spinner. The composer/tab trigger shows the provider glyph filled with the two colors as a static 45° gradient (`PersonalityProviderIcon`); the left sidebar stays theme-generic by design. +- **Voice:** a per-utterance `SpeechVoiceOverride` threads through `synthesizeSpeech(text, voice?)` → `TTSManager` → the provider. Sherpa resolves the voice name to a local speaker id (soft binding); OpenAI honors a valid OpenAI voice name. A personality agent speaks in its own voice in realtime voice mode. See [voice architecture](../public-docs/voice.md). + +## Voice cues + +An agent can **speak** a short line in its own personality voice at four lifecycle moments — it **joins** (**starting**: "On it"), it **first starts thinking** ("Working on it"), it finishes its own turn while its observed sub-agents are still running (**waiting**: "Still hearing back"), and it **completes** a turn ("Done"). Each moment's lines must read as unmistakably that moment — a generic line like "All set" that fits starting/thinking/done equally is the anti-pattern the generator and editor steer away from. On by default; the toggle and its volume are **Settings → \ → Agents → "Voice cues"** (device-local `agentVoiceCues` + `agentVoiceCuesVolume`). + +**Quick mute in the workspace header.** `voice/workspace-voice-cues-button.tsx` sits in the title cluster immediately left of the Visualizer button (speech glyph, `RecordVoiceOver` / `VoiceOverOff`), styled and sized exactly like it — accent while unmuted, `lg` glyph in compact, `md` on desktop. + +**Mute is not disable.** The button writes `agentVoiceCuesMuted`, never `agentVoiceCues` — the same split the Visualizer has between its feature switch and its in-page speaker button. `agentVoiceCues` is "do I want this feature at all" and lives in settings; the mute is "not right now" and lives one click from where you work. Consequences worth stating, because collapsing the two reads fine until you try it: muting leaves the button on screen showing its muted glyph, while **disabling cues in settings removes the button entirely** — a mute for something switched off is a control over nothing, and a button that turned itself invisible would be a trap. Playback needs both (`agentVoiceCues && !agentVoiceCuesMuted`). + +`useVoiceCuesAvailable()` owns that gate (host capabilities **and** the enable flag). The button joins the compact header's width budget as `voiceCues` — **first to drop** (see `compact-header-actions.ts`), because it is the only button in that row whose loss costs no capability: cues keep playing, and the switch is still in settings. + +**This is a notification channel, not a Visualizer feature.** Cues were born in the Visualizer and were briefly mounted by its panel on the same `ready && isVisible` gate as the event adapter, so they could only speak for the one workspace whose Visualizer tab happened to be frontmost — a notification channel that only works while you are looking at it is not one. Playback now runs app-globally with **no visibility, focus, or Visualizer condition at all**, and with none of the visual performance: the render bundle isn't loaded, no canvas exists, only the audio fires. Disabling the Visualizer does **not** silence cues, and neither does muting it. What remains: the `agentVoiceCues` setting, the cue channel's own volume, and the host capabilities. + +Scope decisions (locked): **only the main/root agent speaks** (a fan-out of subagents never becomes a chorus), **only personality-backed agents** (an agent with no bound personality is silent), and **only on a host that advertises both `visualizerVoiceCues` and `ttsPreview`** capabilities. The first flag keeps its historical name because it is a wire key — renaming a `server_info.features` field would break the contract with older daemons. + +How it's wired — entirely host-side, **no vendor patch** (playback can't live in the CSP-locked webview): + +- **Lines are stored on the personality, not generated at runtime.** Each personality carries an optional `voiceCues { join?, thinking?, waiting?, done? }` (`AgentPersonalitySchema`) — a few short editable variations per moment. They're authored and hand-tuned in the **personality editor's Voice tab**: a "Generate with AI" button fills the fields via the daemon's Writer mini-task chain, and **on save, if the cue fields are still empty, they're auto-generated** so every personality ends up with a set. Generation lives in `packages/server/src/server/agent/voice-cue-generator.ts` (Writer chain, flavored by the persona's `name` + `prompt` + `roles` — a "researcher" and a "coder" get different quips) behind the `visualizer.voiceCues.generate` RPC (`session.ts` `dispatchVisualizerMessage`) — the persona is passed **inline** (name + prompt), not by stored id, so an unsaved draft can generate too. The editor fans out **one request per moment** (the optional `moment` field on the request: `join` / `thinking` / `waiting` / `done`), each a focused single-moment prompt — this both keeps the moments distinct (the model can't blur four arrays it fills in one shot) and drives a **determinate progress bar** in the Voice tab (N of `CUE_MOMENTS.length`, hidden when idle). Closing the editor mid-generation discards the draft, so any lines that land after close are dropped unsaved. Omitting `moment` runs the legacy all-in-one path (still used by older clients). The `visualizerVoiceCues` capability marks that the daemon can author lines (Writer chain, always available); playback separately needs `ttsPreview`. +- **Runtime just reads the stored cues.** `use-agent-voice-cues.ts` looks up the agent's personality in the roster and picks a random line from `personality.voiceCues` — **no runtime generation, no cache** (a personality with no stored cues stays silent). This removes the first-cue latency and per-restart regeneration of the old lazy approach. +- **Audio** reuses the existing `speech.tts.preview` path: the client synthesizes the picked line with the personality's TTS `voice` via `client.previewTtsVoice` and plays it through the shared `voice-context` audio engine — exactly what the voice-preview button does. +- **Orchestration** is `packages/app/src/voice/use-agent-voice-cues.ts`, mounted **app-globally** by `agent-voice-cues-host.tsx` — a headless component in `_layout.tsx`'s ProvidersWrapper (inside `VoiceProvider` so the shared audio engine resolves, above the router so it never unmounts on a route/tab change), one hook instance per connected host with `workspaceId: null` = every workspace. It subscribes to the session store, watches root-agent status transitions (`join` = new agent, `thinking` = first `running`, `done` = `running`→`idle`), and — critically — **seeds already-present agents silently** so attaching to an in-flight session never re-announces history. A module-level dedupe coalesces the same cue across hook instances, and playback never talks over an in-flight cue (`engine.isPlaying()`). +- **`waiting` is a DEFERRAL of `done`, not a replacement.** There is no "waiting" agent status to watch — the condition is composed: the parent's turn finalized (`running`→`idle`) **and** at least one **observed** sub-agent in its track is still running (`hasRunningObservedSubagent` in `subagents/select.ts`, the same track membership the subagents rail renders, so an _attended_ child — its own chat, its own cues — never holds the parent). So the `running`→`idle` edge only records a **debt** (`doneDeferred`); each later store tick either speaks `waiting` once (`waitingAnnounced`, because observed rows land over several ticks) and holds, or, once the fan-out has drained, pays the debt by speaking `done`. A new turn (`running` again) cancels a pending debt — the stale `done` never fires. A personality authored before this moment existed has no `waiting` lines and simply stays silent, still getting its `done` when the sub-agents finish. + +**Volume is its own channel.** Cues scale to `agentVoiceCuesVolume`, **not** the Visualizer's sound volume — they were briefly shared, which meant muting a graph's ambience silently muted your notifications, and one level had to serve two unrelated purposes. The voice engine has no per-play volume, so the level is applied by scaling the PCM samples by `agentVoiceCuesVolume/100` (linear amplitude, the same shape the vendor page's gain node uses on its own effects), read live via a ref so the slider tracks without tearing down the subscription. Only the PCM default is scaled; a non-PCM TTS format (e.g. mp3) plays unscaled. 0% is silence; the toggle is the real off-switch. + +**Throttling** is three layers deep, because app-wide firing makes "every agent in every workspace cues at once" a real failure mode rather than a theoretical one: a per-(agent, moment) dedupe window (`CUE_DEDUPE_MS`), an **app-wide rate limit** of one cue start per `CUE_GLOBAL_MIN_INTERVAL_MS` (claimed _after_ the line lookup, so a silent agent never burns the slot for a speaking one), and the pre-existing `engine.isPlaying()` guard. Everything over the limit is **dropped, never queued** — a stale "Done" arriving 30 seconds late is worse than silence. + +Caveat: the very first cue on **web** may be silent until the user has interacted with the page once (browser autoplay unlock — cues fire without a fresh gesture). + +## Memory (accrued lessons) + +A personality **accrues lessons across sessions** and carries them into every later spawn. Naming an agent and giving it a role is a claim about continuity, and continuity without memory is cosmetic — every spawn used to start from zero. + +Underneath these are just stored memories: a flat list of text entries keyed to the personality id. No graph, no embeddings, no per-personality storage tier. The capability flag is `server_info.features.personalityMemory`, tagged `COMPAT(personalityMemory)`; without it the client hides the whole feature, since storage is daemon-side by definition and there is nothing a client-side fallback could read. + +### Storage + +`$OTTO_HOME/personality-memory/.json` — one file per personality, atomic writes, no migrations (see [data-model.md](data-model.md)). Entries carry `text`, `scope` (`project` | `global`), an optional `projectRoot`, timestamps, a `source` (`agent` | `user` | `review` | `transfer`), a `reinforcedCount`, and an optional `transferredFrom`. + +**One file per personality, not one file per fact.** The harness's own one-fact-per-file layout exists because an _agent_ maintains that index by hand; here the daemon maintains it, so splitting buys nothing and costs the atomicity that makes transfer-on-delete a single write. Every mutation goes through a **per-personality serialized read-modify-write queue** — two agents spawned from one personality can record concurrently, and a lost increment there is a lost lesson. Caps: 200 entries per personality, 1,200 chars per lesson. + +**Keyed to the personality id, never the agent.** The agent is ephemeral; the personality is the continuity. An agent whose personality has since been deleted from the roster still keeps that identity's lessons — the spawn snapshot outlives the roster entry, and so does its memory. + +**Scope is resolved, not configured.** A lesson defaults to the current project and an agent can mark one `everywhere`; injection is `global ∪ thisProject`. The project root is the **git repo root**, resolved daemon-side, so a worktree and its main checkout share one project's lessons. A client must never compute this itself — it would disagree with the daemon the moment a worktree is involved, and then the brief shown would not be the brief injected. + +**Every project-scoped WRITE carries a project too, or it is refused.** The filter compares roots (`selectEntriesForProject`), so an entry with `scope: "project"` and no `projectRoot` matches no project and is injected **nowhere** — while the Memory tab still lists it. That is the worst possible failure: storage and injection disagreeing, silently. So `personality.memory.update.request` takes the same `workspaceId` the list request does, `Session.resolveMemoryRequestRoot` resolves both sides through one function, and a project-scoped write with no resolvable root is **rejected** rather than stored. `revise_lesson` passes the calling agent's `cwd` for the same reason — a lesson moved from `everywhere` to `project` needs a root to move _to_. + +On revise, the **existing** binding wins over the caller's: the Memory tab lists every project's lessons, so editing one while standing in another repo must not re-home it. The caller's root only fills a gap, which is exactly what a global→project move needs. + +**"Nothing is injected" and "nothing is stored" are different facts.** The tab says which. An empty brief above a populated list means the lessons are scoped elsewhere, and each row says so — `Another project` for a different root, `No project — never sent` for a legacy unattached entry (editing one and saving re-binds it here). Reporting only "nothing is added" while rows sit below it reads as a bug rather than as scoping, which is how this defect was found. + +### The three tools + +Registered on the daemon's existing MCP catalog, so **every provider gets them at once** — Claude, Codex, OpenCode, and an openai-compatible local model alike. They fall in the existing `agents` tool group (`ottoToolGroupForName` routes unprefixed names there), so the per-group allowlist can switch them off; a **new** group value was rejected because `OTTO_TOOL_GROUPS` is a wire enum an older peer could not parse. All three resolve the calling agent's personality from `callerAgentId` and fail with a named error when there is none — memory belongs to a personality, not to a single chat. + +| Tool | Shape | Ergonomics | +| ----------------- | ----------------------------------------------- | --------------------------------------------------------------- | +| `remember_lesson` | `{ lesson, scope?: "project" \| "everywhere" }` | Fire-and-forget. Returns `added` or `reinforced` — never an id. | +| `review_lessons` | `{}` | Every lesson with a short handle, plus the review protocol. | +| `revise_lesson` | `{ handle, lesson?, scope?, forget? }` | One reviewed outcome. Only reachable through `review_lessons`. | + +**Recording is fire-and-forget, and that is the load-bearing design decision.** The agent states what it learned — no ids to track, no file to choose, no index to maintain. If recording were any harder than that, agents would not do it. Which means **dedup is the daemon's job, not a discipline in the prompt**: `lesson-dedup.ts` scores lexical token overlap (Jaccard over significant tokens) and a near-duplicate **reinforces** the existing entry instead of adding a row. Deliberately lexical — no model, no network, microseconds, and trivial to reason about when it gets one wrong. The threshold sits high (0.75) because the failure modes are asymmetric: a missed duplicate costs one redundant line that `review_lessons` will consolidate, while a false merge silently destroys a distinct lesson. At 0.7, two six-word lessons differing by a single discriminating token scored 0.71 and merged. + +Only **same-scope** entries are dedup candidates. "Always true here" and "always true everywhere" are different claims even in identical words; merging them would silently widen or narrow a lesson's reach. + +**`review_lessons` is the deliberate counterpart, with the opposite ergonomics.** Its description carries the protocol, because the protocol _is_ the feature: read the lessons back, look for ones that are wrong, too vague to act on, or overlapping, **ask the user about them rather than guessing**, then call `revise_lesson` per outcome. A model that rewrites without asking has laundered its own assumptions into permanent storage. This is also why there is no scheduled consolidation pass: an unattended rewrite of behavioural rules is the one thing worth never automating. + +### Injection + +**Where:** `AgentManager.prepareSessionConfig` — the single choke point every spawn, resume and refresh path already funnels through (composer, MCP `create_agent`, schedule runs, orchestration runs, reattach). One site, above every provider adapter, no per-caller threading. The live personality switch composes it through the same helper (`withPersonalityMemory`), so a personality behaves identically however you attached it. + +**Runtime-only, never stored.** The brief is appended to the **launch** config's system prompt and deliberately not to `storedConfig`, mirroring how `daemonAppendSystemPrompt` is re-derived on resume. Two consequences, both wanted: + +1. Memory is **re-read on every resume** — a lesson recorded yesterday is present today without rewriting any agent record. +2. The live-switch prompt-ownership check (`config.systemPrompt === outgoingComposedPrompt`) keeps comparing memory-free prompts, so it cannot start failing the moment a personality learns something. + +**Independent of `respectGlobalAppendPrompt`.** That toggle governs the _daemon-global_ append prompt; these lessons are the personality's own, so a personality that stands alone still gets them. + +**The brief is composed by one pure function** (`memory-brief.ts`), and that is what makes the feature inspectable: the RPC serving the UI and the spawn path injecting the prompt call it with the same inputs and get the same string, so what you are shown cannot drift from what is sent. Ordering is the budget policy — most-reinforced first (a lesson relearned three times has earned its place over a one-off), then most-recently-updated, then id for stability, because a prompt that reshuffles between spawns defeats provider prompt caching for nothing. The cap is `MEMORY_BRIEF_TOKEN_BUDGET` (1,500 tokens ≈ 0.75% of a 200K window, ≈ 4.7% of a 32K local model's — the constituency that actually feels it), and when entries are dropped **the brief says so and names `review_lessons`**: a silent truncation would make the injected set differ from the shown set. + +The brief's preamble does two things a bare list cannot — it tells the model these are its **own** prior conclusions rather than a user instruction to obey blindly, and it says what to do when this session contradicts one. That sentence is the difference between memory and dogma, and it is asserted in tests rather than left to prose review. + +**`memoryEnabled` on `AgentPersonality`: absent means ON.** A personality with no lessons injects nothing and costs nothing, so an off-by-default switch would only mean the feature never starts working for anyone who did not go looking for it. The switch exists to stop a personality accruing, not to start it — and the editor writes the field **only when false**, so the default state stays absent on the wire. + +### Where you see and manage it: Context Management + +Context Management owns "everything sent before you type", and memory is part of that, so the surface lives there rather than in the personality editor — per-personality editing scattered into a settings dialog would need list and diff tooling that surface does not have, and would split memory across two places. + +- **A "Viewing context for" selector** in the summary, beside the window presets (both answer the same question, so they share a visual idiom). Picking a personality re-requests the report with `personalityId`, so the category bars and the working-room figure include that personality's memory. **"Everyone"** is a real selectable answer, not a null state. +- **A Memory sidebar tab**, beside Context and Worth fixing, badged with the lesson count and **never toned** — lessons are not a problem to fix, and amber would read as "this personality learned something wrong". Absent entirely on a host without the capability. +- The tab shows **the injected brief verbatim** with its recurring cost, then the stored rows: editable in place, deletable, with an explicit scope toggle and an add-by-hand affordance. + +Two shape decisions worth keeping: + +- **Memory is not a node in the graph tree.** Tree rows open in the file pane and a lesson is a stored row, not a file; a row that opened a nonexistent path would be a worse lie than not being there. +- **No new `ContextCategory` member.** That schema is a `z.enum` travelling daemon→client, so a new value would make a new daemon's report unparseable by an older client. Memory weight folds into `otto_injected`, which is literally what it is: prompt text Otto composes and injects. The report also carries additive `personalityId` / `personalityMemoryTokens`. +- **Editing does not reuse Refine**, deliberately. Refine works on a _set of files_ and reviews a diff hunk by hunk — complexity that earns itself on a long document and is pure overhead on a two-sentence lesson. The model-assisted path for improving a lesson is `review_lessons`, which asks the user questions, which is the thing a diff review cannot do. `compact-memory-index` keeps its own job: the harness's `MEMORY.md`, which _is_ a file. + +### Personality dialogs: accrual, not management + +The list row shows `Used N times · N lessons` (silent at zero), and the editor's Personality tab carries a read-only count plus the **Remember lessons** switch, pointing at Context Management for the rest. A deliberate scope limit: enough that you would not delete a personality casually, and no CRUD. + +### Transfer on delete + +Deleting a personality that has lessons asks a **three-way question** instead of a yes/no confirm (`personality-memory-transfer-sheet.tsx`): transfer them to another personality, discard them deliberately, or cancel. Lessons are the only part of a personality that took real work to produce and there is no undo. Zero lessons keeps the plain confirm — a decision sheet about nothing is an extra click. + +- The destination list puts **same-role personalities first** (roster order within each group) because that is overwhelmingly the intent: you are replacing a Coder with another Coder. Pure reordering, nothing excluded — a user moving a Coder's lessons onto an Orchestrator may know something the role tags do not. +- Transfer **merges** into the destination rather than renaming the file: the destination usually already has lessons, and clobbering them to save a merge would destroy exactly what this operation exists to preserve. Near-duplicates merge with their reinforcement counts **added**, because two personalities independently learning the same thing is stronger evidence than either alone. Moved entries get fresh ids (a reused id collides the second time the same lessons are transferred) and are stamped `source: "transfer"` + `transferredFrom`. +- **Memory resolves before the roster write.** Reversing that order would delete the owner of a store nobody can then hand over; a failed transfer leaves both the personality and its lessons intact. An old client deleting a personality without this flow orphans the file rather than destroying it. + +### Provider parity + +Nothing here is Claude-shaped. The tools ride the daemon MCP catalog, injection sits above every provider adapter, and the review loop runs inside the agent's own session — so a local LM Studio model gets this exactly as a hosted frontier API does. No daemon-side generation is involved; anything model-assisted added later resolves through `resolveStructuredGenerationProviders`, the same chain Refine and the mini-tasks use. + +### Considered and rejected + +Kept because "we thought about it and said no" is the only useful form of a deleted idea, and each of these looks reasonable enough to be proposed again. + +| Rejected | Why | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Memory tiers** (off / simple / structured) | A tier is bookkeeping the user has to think about — the same failure as making recording hard, aimed at the user instead of the agent. One switch, one representation. | +| **A memory index plus a `recall` tool** | Costs a tool and a round trip, and makes remembering conditional on the model choosing to look. The entry set is capped and small, so the full text goes in. | +| **Editing in the personality editor** | Context belongs in one place, and the editor would need the list and diff tooling that surface does not have. The editor shows accrual; Context Management manages. | +| **A scheduled consolidation pass** | `review_lessons` _is_ the consolidation pass, and it asks the user. An unattended rewrite of behavioural rules is the one thing worth never automating. | +| **A shared per-team memory pool** | A team is a selection of personalities, not an identity that learns. If it becomes one, it gets its own store keyed by team id. | +| **A new `ContextCategory` for memory** | That schema is a `z.enum` travelling daemon→client; a new member breaks older clients. `otto_injected` is what the weight actually is. | +| **A new `OTTO_TOOL_GROUPS` value** | Same reason, in the other direction: a new client could send `"memory"` to an older daemon that cannot parse it. The tools live in `agents`. | + +## Otto tooling + +Personalities are first-class in the agent-management MCP tools, so multi-agent skills can say "spawn a Worker and a Judger" without hardcoding providers: + +- **`create_agent`** gained an optional `personality` arg (by name; one of provider/personality required). It resolves against the caller cwd's provider snapshot and expands to provider/model/effort/mode/systemPrompt; explicit sibling fields override per-field. Hard-fails when the personality is missing or out of commission. +- **`list_personalities`** enumerates the roster (name, roles, availability, resolved brain, plus the `tier`/`canLaunch`/`guidance` decision-aid). **Open to every agent** — personalities are aware of each other, and any agent can enumerate the roster and spawn any personality by name (personality-named spawns are just another way to pick a provider/model/effort). No role gates this; the coordinator/focused tier only steers behavior in-context (see [Role tiers](#role-tiers-coordinators-vs-focused-workers)). +- **`create_schedule` / `update_schedule`** accept a `personality` arg; a bound schedule re-resolves against the run's workspace each run and hard-fails on unavailability. + +Separately from the MCP tools, the **`agentPersonalities.get_stats`** WebSocket RPC serves per-personality spawn counts from a separate atomic-write stats file under `$OTTO_HOME/stats/` (not `config.json` — avoids spamming the config-changed broadcast). Spawns are counted at the `AgentManager.createAgent` choke point (`onPersonalitySpawn`), so composer, MCP `create_agent`, and schedule runs all increment. The editor surfaces "Used N times" per row. + +The five `skills/*/SKILL.md` files teach role-aware discovery: committee prefers contrasting `advisor`/`judger`, advisor prefers `advisor`, handoff prefers `coder`, loop maps worker→`coder`/verifier→`judger`. + +## Editor + +Personalities are authored in the **Agent personalities** card (Host settings → Agents, `agent-personalities-section.tsx`), feature-gated on `features.agentPersonalities`. It reads/writes the roster via `useDaemonConfig`, so every save round-trips through `daemon-config-store.ts` and hot-reloads to all connected clients. + +- **List rows** show name, `provider · model · roles`, a live `BlobLoader` in the row's spinner colors, "Used N times" (from `agentPersonalities.get_stats`) and "N lessons" (from `personality.memory.stats`, silent at zero — see [Memory](#memory-accrued-lessons)). Rows for out-of-commission personalities are grayed out with a reason via the shared `checkPersonalityAvailability` predicate — the same availability logic the pickers use. +- **Edit modal** (`PersonalityEditModal`) is **tabbed** (the form grew long) — a `SegmentedControl` across three tabs, all editing the one `draft` (fields are conditionally rendered, so switching tabs never loses in-progress edits), with the Cancel/Save actions pinned below the tabs: + - **Identity** — name, personality-prompt textarea, respect-global-append toggle, role chips with an **All / None** toggle, and the two spinner **color inputs** (wheel + hex text) with a live `BlobLoader` preview. + - **Model** — provider → model → mode → effort pickers sourced from the live providers snapshot. + - **Voice** — the **TTS voice picker** (from `getSpeechSettingsOptions`, shown only when the host exposes voices) plus the **Voice cues editor**: the cue groups (Starting / Thinking / Waiting / Completed), each an editable list of short lines (add/remove, stable-id keyed rows), with a **"Generate with AI"** button that authors a set from the draft's name + prompt via the `visualizer.voiceCues.generate` RPC. **On save, empty cues are auto-generated** (best-effort — a generation failure saves without cues rather than blocking). See [Voice cues](#voice-cues). + +The editor enforces the invariants that make a personality safe to reference: + +- **Unique name, case-insensitive.** Names are load-bearing keys (spawn-by-name, running-agent selection), so a draft that collides with any other personality's name blocks save and shows an inline error. The check excludes the personality being edited. +- **Valid hex spinner colors.** Glow colors flow into daemon config, SVG gradients, and the `BlobLoader`; a hand-typed color must parse as hex (`#rgb`/`#rgba`/`#rrggbb`/`#rrggbbaa`) before save is enabled — the invalid input shows destructive styling and its swatch stays empty. The color wheel always emits valid values. +- **Double-submit lock.** Save awaits the config round-trip with the button locked, so a double-click can't mint a duplicate personality; the parent unmounts the modal on success and surfaces save errors itself. +- **Dirty-discard confirm.** Cancel/backdrop-close on a modified draft confirms before discarding (exact dirty check via a stringify of the JSON-safe draft against its initial value); a pristine draft closes immediately. +- **Concurrent-edit safety.** If the personality being edited vanishes from the roster mid-edit (deleted from another client), save re-appends it instead of silently dropping the mapped update. + +## Starter team + +A fresh host seeds a **starter team** so the editor opens with a working, role-complete roster instead of empty. Single source of truth: `DEFAULT_AGENT_PERSONALITIES` in `packages/protocol/src/default-personalities.ts`, imported by the daemon (first-run seeding) and the app (restore button). Six personalities, all Claude, covering all 8 roles: **Atlas** (orchestrator, chatter), **Sage** (advisor), **Vera** (judger), **Pixel** (artificer), **Dash** (writer, scheduler — the fast Haiku scribe for commit messages/summaries/names), **Sprocket** (chatter, coder). + +Seeding is first-run-only and delete-safe: `bootstrap.ts` seeds the in-memory roster only when the persisted `agents.agentPersonalities` section is **absent** (distinct from an empty roster the user cleared), then `seedDefaultPersonalitiesIfAbsent` records it on disk once, writing only the personalities branch. Once the section exists on disk (even empty), seeding is a permanent no-op — **deleting the whole team sticks across restarts.** The editor shows "Add starter team" in the empty state and "Restore starter team (N missing)" as a footer, re-adding only builtins whose stable `personality_builtin_*` id is missing. + +## Where the code lives + +- **Shared (app + daemon):** `packages/protocol/src/messages.ts` (`AgentPersonalitySchema` incl. `memoryEnabled`, `PERSONALITY_ROLES`, the `agent.personality.set` and `personality.memory.*` schemas), `agent-personalities.ts` (role helpers, availability predicate), `default-personalities.ts`, `effort.ts`. +- **Daemon:** `packages/server/src/server/agent/agent-personalities.ts` (resolution + snapshot), `agent-manager.ts` (`setAgentPersonality` live switch, `prepareSessionConfig` memory injection), `session/agent-config/agent-config-session.ts` (the `agent.personality.set` RPC envelope), providers' optional `applyPersonality` (`providers/claude/agent.ts`, `providers/openai-compat-agent.ts`), `daemon-config-store.ts` (persistence/seeding), `tools/otto-tools.ts` (`create_agent`/`list_personalities`, the three memory tools), `PersonalityStatsStore`. +- **Memory (daemon):** `packages/server/src/server/agent/personality-memory/` — `types.ts`, `personality-memory-store.ts` (file-backed, serialized RMW), `lesson-dedup.ts`, `memory-brief.ts` (the pure composer), `personality-memory-service.ts` (the façade every caller talks to). Wired in `bootstrap.ts`; RPCs handled in `session.ts`. +- **App:** `packages/app/src/screens/settings/agent-personalities-section.tsx` (editor), `screens/settings/personality-memory-transfer-sheet.tsx` (transfer on delete), `components/combined-model-selector.tsx` (picker section), `hooks/use-personality-selection.ts`, `provider-selection/personality-form.ts`, `composer/agent-controls/index.tsx` (`useRunningChatPersonality`, the running-agent live switch). +- **Memory (app):** `packages/app/src/context-management/` — `use-personality-memory.ts` (RPC hooks), `use-context-personality.tsx` (the panel's bundled wiring), `personality-selector.tsx`, `memory-list.tsx`. diff --git a/docs/agent-teams.md b/docs/agent-teams.md new file mode 100644 index 000000000..9e7cb7013 --- /dev/null +++ b/docs/agent-teams.md @@ -0,0 +1,134 @@ +# Agent Teams + +An **Agent Team** (UI label: "Agent teams", Host settings → Agents) is a named, per-host grouping of [agent personalities](agent-personalities.md) that acts as an **operating template** for the whole host: which personalities are on deck, and a shared **team prompt** that frames how they work together. A user can define many teams — "Shipping crew", "Research panel", "Solo + reviewer" — with any personalities in any of them (membership is many-to-many), but only **one team is active at a time**, and switching the active team is instant from the main UI. + +Teams build directly on the Personalities system and reuse its invariants — stable ids, snapshot-at-spawn, availability, no-fallback hard-fails — rather than inventing new ones. Read [agent-personalities.md](agent-personalities.md) first. Teams shipped in 0.5.2. This doc is the durable architecture; the one remaining follow-up — the themed avatar image set — is tracked in [the projects ledger](../projects/README.md#onboarding--ux). + +## What an active team does + +When a team is active: + +- **Pickers narrow to the team.** The personalities section in every picker (composer, artifact sheet, schedule sheet) shows only the active team's members — still role-filtered per surface, still availability-grayed. Raw provider/model selection is never filtered; teams scope _personalities_, not models. +- **Agents stack the team prompt.** An agent spawned from a member personality composes its system prompt with the team prompt directly ahead of the personality prompt (see [Prompt composition](#prompt-composition)). +- **The rest of the host follows.** Writer mini-task routing prefers team members; the Orchestrator's `list_personalities` enumerates the team. + +**No active team = exactly legacy behavior.** The full roster shows everywhere and no team prompt stacks. "No team" is the implicit default, not an error — it degrades cleanly, the same shape as every other feature gate in the repo: + +- **No personalities** (roster cleared): pickers show no personalities section at all — just the raw provider/model chooser; Writer routing runs the legacy chain; `list_personalities` returns empty. +- **Personalities but no teams**: everything works exactly as personalities ship. The Teams card shows its empty state; the main-window switcher does not render (it requires ≥ 1 team). +- **Teams exist but none active**: full roster in pickers, no team prompt, no scoping — teams are inert until activated. The switcher renders with "No team" selected. +- **Active team whose members are all deleted/unavailable**: the personalities section grays/empties per existing availability rules, and the raw chooser underneath is always reachable — a team can never brick agent creation. + +North-star fit ([CLAUDE.md](../CLAUDE.md)): a team mixing an LM Studio Coder with a Claude Judger is a first-class team; nothing in the model, resolution, or prompt stacking is provider-specific. + +## Data model + +`agentTeams` is a section on `MutableDaemonConfig` (`packages/protocol/src/messages.ts`, alongside `agentPersonalities`, both `.passthrough()`). It holds `teams: AgentTeam[]` plus `activeTeamId: string | null` — the active team is **nested inside the same section**, not a top-level field, so a single patch replaces both together. It persists through `daemon-config-store.ts`'s merge whitelist and hot-reloads over `status:daemon_config_changed`. The capability flag is `features.agentTeams` — tagged `COMPAT(agentTeams): added in v0.5.2`. + +``` +AgentTeam { + id: string // stable, machine-generated; the ONLY thing references bind to + name: string // human label, freely renamable, unique per host (case-insensitive) + avatar?: { + color?: string // hex; the v1 avatar (swatch / colored ring) + imageId?: string // FUTURE themed-image key; wins over color when present, color is the fallback + } + teamPrompt?: string // stacked ahead of the personality prompt at spawn + memberIds?: string[] // personality ids; order = display order in cards & pickers +} + +agentTeams.activeTeamId?: string | null // host-scoped; null/absent = no team active +``` + +- **On the wire everything past `id`/`name` is optional plain strings/arrays** — no enums, no transforms, `.passthrough()` containers — the same forward-compat posture as `AgentPersonalitySchema`. The daemon validates against its own roster when applying a patch. (The patch schema is declared explicitly rather than via `.partial()`: a `.partial()` variant would keep the `teams` `.default([])`, so an `activeTeamId`-only patch would inject an empty array and deep-merge would wipe the stored teams.) +- **Identity is the `id`, never the name.** Renaming a team must not break the active binding or any reference — same invariant as personalities. +- **Membership binds personality `id`s.** Renaming a personality changes nothing; a `memberIds` entry pointing at a deleted personality is **tolerated and ignored** everywhere (resolution, cards, pickers) and pruned opportunistically on the next save of that team. No eager cross-object cascades on delete. Deleting the **active** team clears `activeTeamId` in the same config patch — never a dangling active id. +- **The one cascade is opt-in and user-driven.** Deleting a team computes its **exclusive members** (`resolveExclusiveTeamMembers`, `packages/protocol/src/agent-teams.ts`) — members no _remaining_ team also lists, i.e. the personalities the delete would strand on no team at all — and, when that set is non-empty, the confirm dialog names them and offers an unchecked "Also delete these N personalities" checkbox. Checked, the roster and the teams array go out in **one config patch** so the two sections never briefly disagree. Unchecked (and always, when nothing would be stranded) the roster is untouched — the default stays "personalities are not deleted". +- **Avatar is colors-first, image-ready.** v1 ships only `avatar.color`. The themed image set lands later by adding `imageId` values and an app-side asset catalog — an additive field, zero protocol churn. Old clients that don't know `imageId` keep rendering the color; that is the designed degradation, not a fallback path. + +### Why the active team is host-scoped daemon config (not device-local) + +The team prompt is applied **daemon-side at spawn** — MCP `create_agent`, schedule runs, and mini-tasks all spawn with no client attached, so the daemon must know the active team on its own. Host config also gives the switcher its "instant everywhere" behavior for free: a patch from any client hot-reloads to every connected client via `status:daemon_config_changed`, same as every other config edit. A device-local active team would fork reality between phone and desktop and leave headless spawns teamless. + +## Prompt composition + +The one rule, applied at every spawn path (`composeTeamAndPersonalityPrompt`, `packages/server/src/server/agent/agent-teams.ts`): + +> **If the spawning personality is a member of the active team at spawn time, the team prompt stacks directly ahead of the personality prompt.** + +The personality-owned prompt composes top to bottom as **team prompt** (frames the collective) → **personality prompt** (specializes within it) → **role-focus directive** (`composeRoleFocusDirective`; tells a coordinator "orchestration is yours" or a focused worker "stay on task"). Then the existing global-append machinery runs unchanged (`applyDaemonAppendSystemPrompt`, `agent-manager.ts`), stacking the daemon-global `appendSystemPrompt` unless the personality set `respectGlobalAppendPrompt: false`. So the full stack: **provider base → team prompt → personality prompt → role directive → global append**. With no team layer and no roles the personality prompt passes through byte-identical to pre-teams behavior. + +Deliberate boundaries of the rule: + +- **Raw spawns (no personality) get no team prompt.** Teams are compositions of personalities; picking a raw provider/model is an explicit step outside the roster. +- **Non-member personality spawns get no team prompt.** Pickers only offer members while a team is active, so this arises only via an explicit MCP `create_agent personality:"X"` — explicit is explicit; the spawn succeeds without the team layer. +- **`respectGlobalAppendPrompt: false` does NOT suppress the team prompt.** That toggle governs the daemon-global append only. Putting a personality on a team opts it into the team frame; the team prompt is part of the identity stack, not the global append. +- **Empty/whitespace `teamPrompt` stacks nothing** — a team can be purely organizational (picker scoping) with no prompt. +- **Caller-authored prompts still win.** When the caller sets an explicit `systemPrompt`, neither the personality prompt nor the team prompt composes in — team prompt only rides the personality-owned prompt path. + +### Snapshot semantics + +At spawn the active team resolves to a frozen `ResolvedTeamSnapshot` (`{ teamId, name, avatarColor?, teamPrompt? }`) on `AgentSessionConfig.teamSnapshot`, persisted via `SERIALIZABLE_CONFIG_SCHEMA` next to `personalitySnapshot`. It mirrors the personality snapshot's lifecycle exactly: + +- **Switching the active team never mutates a running or observed agent.** Agents keep the snapshot they were born with; switching changes only what _new_ spawns get, instantly, and nothing else. +- **A live personality switch keeps the born team.** `agent.personality.set` swaps only the personality layer; the prompt recomposes as `frozen teamSnapshot.teamPrompt + new personality prompt`. Switching to a personality outside the frozen team keeps the team prompt anyway — the agent's team is part of its birth identity, like its cwd. + +The three daemon spawn paths that compose the team layer — interactive create (`session.ts` `applyPersonalityIdentityToConfig`), MCP `create_agent`, and schedule runs — all resolve through the one helper in `agent/agent-teams.ts` (`resolveTeamSnapshotForPersonality`), so the logic lives in one place. A schedule **resolves the active team at run time**, not at authoring time: a schedule that fires under Team B runs with Team B's framing (iff its bound personality is a member; otherwise no team layer — teamlessness is a valid state, never a hard-fail). + +## What the active team gates (beyond the prompt) + +- **Pickers** (`use-personality-selection.ts` / `combined-model-selector.tsx`): with a team active, the personalities section shows only members — **strict, no off-team group**. One exception: a schedule form editing a schedule already bound to an off-team personality keeps that bound entry selectable (it was valid when authored). The running-agent switcher also filters its pinned roster to team members, but the display-only entry for the _current_ personality keeps working when it's off-team so the trigger never lies. +- **Writer mini-task routing** (`resolveStructuredGenerationProviders`): available Writer **team members** are prepended ahead of available non-team Writers, which stay ahead of the legacy chain. This is a preference ladder, not an availability gate — an all-out-of-commission team must not break commit-message generation. (This is the one place "no fallback" does not apply; it never applied to mini-task routing.) +- **MCP `list_personalities`**: with a team active, returns members only, plus a note naming the active team (so an Orchestrator knows its bench). `create_agent` by explicit personality name still resolves the full roster — an Orchestrator can pull in an off-team specialist deliberately, it just won't carry the team prompt. +- **Role-matched daemon lookups** (Writer routing, `checkout.git.commit_agent`, etc.): prefer team members with the role, fall back to the full roster — same ladder as Writer routing. + +Availability stays per-personality — a team is never "out of commission"; its members individually are. + +### The "Team's <Role>" dynamic binding + +Picker surfaces that pick a personality for a role offer a synthetic **"Team's <Role>"** entry alongside concrete personalities (the schedule form's **"Team's Scheduler"**, the artifact sheet's **"Team's Artificer"**), all built by the shared `buildTeamRoleEntry` (`packages/app/src/provider-selection/team-role-entry.ts`): + +- **Binding semantics:** the surface owns how selection persists — a schedule stores a run-time **sentinel** (not a personality id) and re-resolves each run; an artifact just applies the resolved values now. At resolution the entry maps to the active team's **first available member carrying that role**, in `memberIds` order. +- **Hard-fail on the daemon side.** `resolveTeamSchedulerSnapshot` (`agent/agent-teams.ts`) throws a named error when no team is active, when the team has no member with the role, or when none are available — the same loud semantics as a bound personality being out of commission, never a silent fallback. +- **Form affordance:** the picker entry shows who it resolves to _right now_ ("currently Dash — changes with the active team"). +- **Who gets picked by default** is `autoSelectDefault` on the shared producer (`useFormRolePersonality`). Every apply-now surface — new chat, new schedule, new artifact — passes `"always"` and runs the same ladder: the team entry, else the device's last-used personality for the role, else the first available personality carrying it, else the form's own model resolution. The full five-tier contract, and which surfaces write preferences at all, is documented in [agent-personalities.md](agent-personalities.md#what-the-model-picker-shows-the-precedence-ladder) — that section is the source of truth; this one only covers the team's slot in it. + - **An active team always outranks device memory.** The team is an explicit, host-level choice; a last-used personality is a device-local leftover. Memory used to win here, and it was a one-way latch rather than a preference: the first personality pick wrote both `lastPersonalityByRole` and the device's last-used model, so the remembered entry matched on every subsequent draft and the team's holder could never auto-apply again — the team entry deliberately persists no last-personality, so nothing could ever clear it. Nothing on the ladder writes a preference on the user's behalf any more, which is what keeps this from re-latching. + - This also closes a scoping hole: the old match-gated preselect resolved against the **full** roster while the up-front picker list is filtered to team members, so an off-team personality could be preselected into the trigger while being absent from the list behind it. Tier 2 now reads the filtered list, so it can only ever pick something the picker actually shows. + +#### Load order: nothing may settle before it could have resolved + +Every input this arbitration reads arrives asynchronously and from a different source — the roster and teams live in daemon config (react-query, keyed on a `serverId` that is itself null until host auto-select), `features.agentTeams` lives in the session store, the provider snapshot streams per-cwd, and device preferences hydrate from local storage. New chats kept landing on a bare model because each stage settled on an early render where the answer could not yet exist. The four rules that keep it honest: + +- **A "loading" provider is not an unavailable one.** The daemon's first snapshot for a cwd lists every provider `status: "loading"`, and `checkPersonalityAvailability` only accepts `"ready"` — so "nothing resolves" during that window means _ask again_, not _nothing is available_. `useFormRolePersonality`'s one-shot default applies the team's holder the moment it first resolves, and only gives up once the snapshot is **settled** (no provider still loading). +- **The team default re-opens when the team slot goes live.** The team's two inputs (daemon config, `features.agentTeams`) land independently, so the ladder may legitimately settle on a tier-2 personality and only then learn a team was active. That transition re-arms the one-shot — and because tier 2 now makes a selection of its own, the default tracks the id it picked (`autoPickedIdRef`) so it can tell its own pick apart from a user's and let tier 1 take over from the former only. +- **Suppressing the remembered preselect un-latches it.** `preselectRemembered` once gated only _new_ preselects, so a single render where it was still true latched the remembered personality permanently and every downstream default then bailed on "something is already picked". It is now computed during render, not dropped in an effect — an effect leaves one render where the stale id is still visible, which is exactly long enough for a default to settle on it. On surfaces with a default it is off entirely: tier 2 absorbed that job, and applies the personality's values rather than only claiming its identity when the form already happened to show them. +- **Preferences must load before the ladder settles.** Deciding while `lastPersonalityByRole` is still hydrating would skip tier 2 and pick "first available" instead — so the one-shot waits on preferences the same way it waits on the snapshot. +- **Re-resolution never reverts an applied value.** `REQUEST_RESOLUTION` (`resolve-agent-form.ts`) keeps the `userModified` flags; only `RESET` — the form actually closing — clears them. It used to clear them on any `resolutionIntentKey` change, and that key flips mid-life whenever a late-arriving `workingDir` turns `undefined` initialValues into real ones, so the next `COMPLETE_RESOLUTION` re-derived from device prefs and silently reverted the model the team's holder had applied while the picker still read "Team's Chatter". + +A fork / "new tab from this chat" sits outside all of this: `WorkspaceDraftTabSetup.personality` carries the source agent's identity into the new draft, seeded as `initialPersonalityId`. Inheriting from a specific agent is a stronger signal than either device memory or the team default, so it is treated as an explicit pick — it suppresses the preselect, survives the un-latch, and blocks the team default. Identity only: the fork's provider/model already arrive through the form's `initialValues`, and deviation keeps identity. + +Coverage for these lives in `provider-selection/role-model-personality.test.tsx` (snapshot arriving `loading` first and `ready` second, feature flag arriving late, inherited seed) and `resolve-agent-form.test.ts`. + +## Team cards & editor (settings UI) + +The **Agent teams** card (`agent-teams-section.tsx`) mounts in Host settings → Agents directly after the Personalities card, feature-gated on `features.agentTeams`, reading/writing via `useDaemonConfig` (the same hot-reload round-trip as personalities). Team cards show the avatar (v1: color swatch/ring), name + member count with a row of small spinner-color dots (one per member in each member's `glowA`), **role pills** (the union of all members' roles), an **Active** badge with "Set active" / "Deactivate" actions, and edit/delete. The `TeamEditModal` mirrors `PersonalityEditModal`'s invariants one-for-one: unique case-insensitive name, avatar color input, team-prompt textarea, a member checklist (≥ 1 member required, availability-graying that never blocks checking), double-submit lock, dirty-discard confirm, and concurrent-edit re-append safety. It is also tabbed the same way (`TabbedModalSheet`): **Identity** (name + team prompt), **Appearance** (avatar color), **Members** (the checklist). The Members tab pins a name/role filter in the sheet's `tabToolbar` slot — above the scroll region, so filtering stays reachable on a long roster — with a clear button that appears once there is something to clear. + +## Main-window switcher + +The "switch instantly from the main UI" surface is the `ActiveTeamSwitcher` (`packages/app/src/components/active-team-switcher.tsx`), rendered whenever the host advertises `features.agentTeams` and has ≥ 1 team. Its default home is a row in the top-left sidebar menu directly above "New workspace"; an appearance setting (`teamSwitcherPlacement`, `sidebar` | `header`) relocates it into the workspace title bar ahead of the other tools, styled like the tool dropdowns. Opening it lists all teams plus a "No active team" entry (current selection checked) and an "Edit teams…" footer deep-linking to Host settings → Agents. + +Selection is **daemon truth**: picking a team patches `agentTeams.activeTeamId` via the daemon-config patch RPC and the control renders from the hot-reloaded config, so every connected client agrees instantly — there is no client-side selection state to drift. The control shows an in-flight spinner until `status:daemon_config_changed` echoes back. Switching is deliberately unceremonious (no confirm dialog); it affects only future spawns, so snapshot semantics make it as safe as changing a default. + +## Starter team seed + +A fresh host seeds `DEFAULT_AGENT_TEAMS` (`packages/protocol/src/default-personalities.ts`): one team, **"The Otto Crew"** (id `team_builtin_otto_crew`), containing the six starter personalities with a short team prompt about working as a coordinated crew. Seeding piggybacks the personalities' first-run/absent-section semantics (`daemon-config-store.ts`): the team is seeded only when the persisted `agentTeams` section is **absent**, so deleting it sticks across restarts. It is **seeded but NOT active** — `activeTeamId` stays unset on first run, so a fresh host behaves exactly like legacy Otto until the user opts in via the switcher (silently activating a prompt-bearing team on install would change spawn behavior out from under existing users). The card offers "Restore starter team" (restore-by-missing-builtin-id), and a guardrail test mirrors `default-personalities.test.ts`: every seeded `memberIds` entry must exist in `DEFAULT_AGENT_PERSONALITIES`. + +## Where the code lives + +- **Shared (app + daemon):** `packages/protocol/src/messages.ts` (`AgentTeamSchema`, `agentTeams` section + patch on `MutableDaemonConfig`, `features.agentTeams`), `packages/protocol/src/agent-teams.ts` (pure helpers: `getActiveAgentTeam`, `resolveTeamMembers`, `resolveExclusiveTeamMembers`, `isTeamMember`, `getEffectiveTeamPrompt`), `default-personalities.ts` (`DEFAULT_AGENT_TEAMS`). +- **Daemon:** `packages/server/src/server/agent/agent-teams.ts` (active-team resolution → `teamSnapshot`, `composeTeamAndPersonalityPrompt`, `resolveTeamSchedulerSnapshot`), `agent-manager.ts` (compose at spawn + `setAgentPersonality` recomposition), `session.ts` (`applyPersonalityIdentityToConfig`), `tools/otto-tools.ts` (`create_agent`/`list_personalities` scoping), `schedule/service.ts` (run-time team resolution), `structured-generation-providers.ts` (Writer team preference), `daemon-config-store.ts` (persistence + seed). +- **App:** `screens/settings/agent-teams-section.tsx` (card + `TeamEditModal`), `components/active-team-switcher.tsx` (switcher), `hooks/use-personality-selection.ts` + `components/combined-model-selector.tsx` (team filtering), `provider-selection/team-role-entry.ts` (`buildTeamRoleEntry`). + +## Deferred + +The **themed avatar image set** (charter step 7) is not built — an additive layer adding ~2 dozen app-bundled images, `avatar.imageId` catalog values, and a picker grid in the edit modal (the schema field already exists and degrades to the color avatar without it). Tracked in [the projects ledger](../projects/README.md#onboarding--ux). diff --git a/docs/android.md b/docs/android.md new file mode 100644 index 000000000..0b111aa7a --- /dev/null +++ b/docs/android.md @@ -0,0 +1,106 @@ +# Android + +## App variants + +Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin): + +| Variant | App name | Package ID | +| ------------- | ---------- | -------------------------- | +| `production` | Otto | `me.ottocode.mobile` | +| `development` | Otto Debug | `me.ottocode.mobile.debug` | + +EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`. + +`development` uses Android `debug`. + +## Local build + install + +From repo root: + +```bash +npm run android:development # Debug build +npm run android:production # Release build +npm run android:clear # Remove generated Android project +``` + +Or from `packages/app`: + +```bash +# Debug +npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive +npx cross-env APP_VARIANT=development expo run:android --variant=debug + +# Release +npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive +npx cross-env APP_VARIANT=production expo run:android --variant=release + +# Clear generated Android project +rm -rf android +``` + +### React version lockstep + +Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen. + +### Windows local builds: `Unable to resolve module ./index.ts` + +On Windows, the `:app:createBundleReleaseJsAndAssets` step fails with +`Unable to resolve module ./index.ts from `. Cause: the React +Native gradle plugin's `Os.cliPath()` rewrites `--entry-file` to a path relative +to the app dir **only on Windows** (to dodge Gradle's space-in-path issues), so +Expo's `export:embed` receives a bare `index.ts`. Expo forwards that relative +entry to Metro unchanged, and in this npm-workspace monorepo Metro resolves it +against the workspace server root instead of `packages/app`. Linux/macOS (EAS) +get an absolute path from `cliPath()`, so cloud builds never hit this. + +Fix (in tree): [`plugins/with-metro-embed-cli.js`](../packages/app/plugins/with-metro-embed-cli.js) +points the gradle plugin's `cliFile` at +[`scripts/metro-embed-cli.cjs`](../packages/app/scripts/metro-embed-cli.cjs), a +wrapper that re-absolutizes `--entry-file` before delegating to `@expo/cli`. The +plugin is gated to `win32` prebuilds, so EAS output is byte-for-byte unaffected. +Note: a `metro.config.js` is **not** a viable fix here — its mere presence makes +Expo take the `mergeConfig` path, which breaks the `.js`→`.ts` source-extension +substitution that workspace packages (e.g. `@otto-code/relay`) rely on. + +## Screenshots + +```bash +adb exec-out screencap -p > screenshot.png +``` + +## Cloud build + submit (EAS) + +> **Fork reality:** on this fork the only thing a tag push triggers on the mobile side is +> `.github/workflows/android-apk-release.yml`, which builds an APK on the fork's own EAS project +> (`otto-code` Expo org) and attaches it to the GitHub Release. There are **no store +> submissions** — no Play Console listing, no Apple account, no EAS GitHub-app store pipeline. +> The upstream description below is kept as reference for if/when store accounts exist; see +> [fork-release-guide.md](fork-release-guide.md). + +Upstream's stable tag pushes like `v0.1.0` trigger: + +- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it, and it is not wired up on this fork. +- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release) — the only part active on this fork. + +Upstream: iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight, and Android auto-submits to the Play Store via EAS-managed credentials. Neither happens on this fork. + +Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores. + +`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag. + +### Useful commands + +```bash +cd packages/app + +# Recent builds +npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}' + +# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page, +# which has a Submissions section showing the auto-submit to the Play Store). +npx eas build:view +``` + +The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store. + +See [docs/release.md](release.md) for the full mobile-build babysitting flow. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..e77544afd --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,345 @@ +# Architecture + +Otto is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents. + +Your code never leaves your machine. Otto is local-first. + +## System overview + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Mobile App │ │ CLI │ │ Desktop App │ +│ (Expo) │ │ (Commander) │ │ (Electron) │ +└──────┬───────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + │ WebSocket │ WebSocket │ Managed subprocess + │ (direct or │ (direct) │ + WebSocket + │ via relay) │ │ + └───────────┬───────┴──────────────────┘ + │ + ┌──────▼──────┐ + │ Daemon │ + │ (Node.js) │ + └──────┬──────┘ + │ + ┌────────────┼────────────┬────────────┬────────────┐ + │ │ │ │ │ +┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐ +│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi │ +│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │ +│ SDK │ │ Server │ │ ACP │ │ │ │ │ +└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────┘ +``` + +## Components at a glance + +- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API. +- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop. +- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon. +- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon. +- **Relay:** Optional encrypted bridge for remote access without opening ports directly. + +## Packages + +### `packages/server` — The daemon + +The heart of Otto. A Node.js process that: + +- Listens for WebSocket connections from clients +- Manages agent lifecycle (create, run, stop, resume, archive) +- Streams agent output in real time via a timeline model +- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter +- Optionally connects outbound to a relay for remote access +- Optionally serves the browser web client from the same HTTP server (self-hosting guide: [public-docs/web-ui.md](../public-docs/web-ui.md)) + +All paths are under `packages/server/src/`. + +**Key modules:** + +| Module | Responsibility | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | +| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | +| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | +| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | +| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$OTTO_HOME/agents/` | +| `server/agent/tools/` | Transport-neutral Otto tool catalog for subagents, permissions, worktrees | +| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Otto tool catalog with the MCP SDK | +| `server/agent/providers/` | Provider adapters (see "Agent providers" below) | +| `server/relay-transport.ts` | Outbound relay connection with E2E encryption | +| `server/schedule/` | Cron-based scheduled agents | +| `server/loop-service.ts` | Looping agent runs that retry until an exit condition | +| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging | +| `server/preview/` | Preview dev-server supervision: launch.json config, spawn/readiness/tree-kill, `preview_*` agent tools — see [preview.md](preview.md) | +| `server/browser-tools/` | Agent-facing `browser_*` tools against real Otto browser tabs: snapshot, inspect, click/fill, eval, console/network capture, tab control | +| `server/artifact/` | Agent-generated HTML artifacts: per-project store, service, file watcher, HTML validation — see [data-model.md](data-model.md) | + +### `packages/protocol` — Wire schemas and shared protocol types + +The source of truth for WebSocket messages, binary frame codecs, endpoint parsing, +agent timeline types, provider config schemas, and other values shared by daemon +and clients. Server, app, CLI, and `@otto-code/client` all depend on this package; +it does not depend on the server. + +### `packages/client` — Daemon client library and SDK facade + +Owns the low-level daemon WebSocket driver plus the higher-level `OttoClient` +facade. App and CLI may import the low-level driver from +`@otto-code/client/internal/daemon-client` during migration, while new SDK-shaped +code imports from `@otto-code/client`. + +### `packages/app` — Mobile + web client (Expo) + +Cross-platform React Native app that connects to one or more daemons. + +- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path. +- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state +- `SessionContext` wraps the daemon client for the active session +- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/` +- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication +- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete. +- Voice features: dictation (STT) and voice agent (realtime) + +### `packages/cli` — Command-line client + +Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `otto ls`, `otto run`). + +- `otto agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode` +- `otto daemon start/stop/restart/status/pair/set-password` +- `otto chat ls/create/inspect/post/read/wait/delete` +- `otto terminal ls/create/capture/send-keys/kill` +- `otto loop run/ls/inspect/logs/stop` +- `otto schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` +- `otto permit allow/deny/ls` +- `otto provider ls/models` +- `otto worktree create/ls/archive` +- `otto speech …` + +Communicates with the daemon via the same WebSocket protocol as the app. + +### `packages/relay` — E2E encrypted relay + +Enables remote access when the daemon is behind a firewall. + +- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption +- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content +- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) +- Pairing via QR code transfers the daemon's public key to the client +- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `OTTO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `OTTO_RELAY_PUBLIC_USE_TLS` + +See [SECURITY.md](../SECURITY.md) for the full threat model. + +### `packages/desktop` — Desktop app (Electron) + +Electron wrapper for macOS, Linux, and Windows. + +- Can spawn the daemon as a managed subprocess +- Native file access for workspace integration +- Same WebSocket client as mobile app + +**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`otto:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `otto ` launch. + +> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. +> +> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up. + +### `packages/website` — Marketing site + +TanStack Router + Cloudflare Workers. Serves otto-code.me. + +## WebSocket protocol + +All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`. + +**Handshake:** + +``` +Client → Server: WSHelloMessage { + type: "hello", + clientId, + clientType: "mobile" | "browser" | "cli" | "mcp", + protocolVersion, + appVersion?, + capabilities?: { voice?, pushNotifications?, ... }, + } +Server → Client: status message with payload { status: "server_info", + serverId, hostname, version, capabilities?, features } +``` + +There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. + +**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). + +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. + +Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default. + +New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names. + +**Notable session message types:** + +- `agent_update` — Agent state changed (status, title, labels) +- `agent_stream` — New timeline event from a running agent +- `workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state +- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow +- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list` +- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations +- Terminal subscribe/input/capture commands +- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`) +- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error` + +`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon +configures the same `searchDirectoryEntries` engine with a root, output format, path-query policy, +entry-kind filters, match mode, blank-query behavior, and hidden-directory traversal policy. A +request without `cwd` searches the host home for absolute project paths; a request with `cwd` +searches that workspace and returns relative entries. Clients may prepend their small host-scoped +recent-project list for bare queries, but must not parse filesystem query syntax or re-filter a +correlated daemon response. The legacy `directories` response field remains a projection of the +typed `entries` list. + +**Binary frames (terminal stream protocol):** + +Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `packages/protocol/src/binary-frames/terminal.ts`. The layout is: + +- 1-byte opcode: `Output (0x01)`, `Input (0x02)`, `Resize (0x03)`, `Snapshot (0x04)` +- 1-byte slot: terminal slot id +- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot + +Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport. + +There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams. + +### Compatibility rules + +- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required. +- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`. +- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. + +Example: adding a new enum value + +```ts +// 1. Add CLIENT_CAPS.newThing = "new_thing" +// 2. Let new clients advertise it in WS hello +// 3. Keep the shared producer schema strict +// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value" +``` + +## Agent lifecycle + +The lifecycle states are defined in `packages/protocol/src/agent-lifecycle.ts`: + +``` +initializing → idle ⇄ running + ↓ ↓ ↓ + error + ↓ + closed +``` + +- `initializing` — provider session is being created +- `idle` — has a live session, awaiting the next prompt +- `running` — provider is currently producing a turn +- `error` — last attempt failed; session is still attached +- `closed` — terminal state, no live session + +`ManagedAgent` is a discriminated union over those lifecycle tags. Notes: + +- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers +- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items +- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics. +- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up. +- Agent state persists to `$OTTO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id. + +## Right-sidebar boundary: directory-backed vs workspace-owned + +Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `local_checkout` workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by **what each piece of state is keyed by**. + +**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:** + +| Surface | Key | Source | +| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- | +| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | +| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` | +| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` | +| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` | +| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` | +| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` | + +**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):** + +| State | Key builder / store | Source | +| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- | +| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` | +| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` | +| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` | +| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` | +| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` | + +`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys. + +**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism. + +One deliberate non-violation: `AgentFileExplorerState.directories`/`files` cache directory listings inside the `workspaceId`-keyed explorer map. Same-`cwd` workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via `listDirectory(workspaceRoot, …)`. This is duplication, not leakage, and is left as-is. + +## Agent providers + +Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`. + +The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use: + +| Provider | Wraps | Session format | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` | +| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` | +| Copilot | GitHub Copilot via ACP | Provider-managed | +| OpenCode | OpenCode server / CLI | Provider-managed | +| Pi | Local Pi RPC process | Provider-managed | +| OMP | Oh My Pi via the Pi RPC client (`omp` binary) | `~/.omp/agent/sessions` | +| OpenAI-compatible | Any OpenAI-compatible endpoint (`openai-compat-agent`); base for custom providers with `extends: "openai-compatible"` — LM Studio, Ollama, etc. | Daemon-managed | +| Cursor / Kiro | ACP wrappers (`cursor-acp-agent`, `kiro-acp-agent`) | Provider-managed | +| Generic ACP | ACP wrapper for the one-click catalog and custom ACP providers | Provider-managed | +| Mock load test | In-process fake | In-memory | + +All providers: + +- Handle their own authentication (Otto does not manage API keys) +- Support session resume via persistence handles +- Map tool calls to a normalized `ToolCallDetail` type +- Expose provider-specific modes (plan, default, full-access) + +Providers that can accept native tool definitions should set `supportsNativeOttoTools` and read `launchContext.ottoTools`. The daemon then passes the shared Otto tool catalog directly and removes the internal Otto MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`. + +## Data flow: running an agent + +1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode) +2. Session routes to `AgentManager.create()` +3. AgentManager creates a `ManagedAgent`, initializes provider session +4. Provider runs the agent → emits `AgentStreamEvent` items +5. Events append to the agent timeline, broadcast to all subscribed clients +6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.) +7. Permission requests flow: agent → server → client → user decision → server → agent + +## Storage + +`$OTTO_HOME` defaults to `~/.otto`. The most important files: + +``` +$OTTO_HOME/ +├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows +├── projects/projects.json # Project registry +├── projects/workspaces.json # Workspace registry +├── chat/ # Chat rooms +├── schedules/ # Scheduled-agent definitions and runs +├── loops/ # Loop runs and logs +├── config.json # Daemon config (mutable) +├── daemon-keypair.json # Daemon identity for relay/E2EE +├── push-tokens.json # Mobile push tokens +├── otto.pid # Daemon PID lock file +└── daemon.log # Daemon trace logs (rotated) +``` + +## Deployment models + +1. **Local daemon** (default): `otto daemon start` on `127.0.0.1:6868` +2. **Managed desktop**: Electron app spawns daemon as subprocess +3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption diff --git a/docs/attachment-lifecycle.md b/docs/attachment-lifecycle.md new file mode 100644 index 000000000..59622cde9 --- /dev/null +++ b/docs/attachment-lifecycle.md @@ -0,0 +1,129 @@ +# Attachment lifecycle + +Agents produce image bytes — browser screenshots, `Read` of a PNG, a chart a tool rendered — and +those bytes have to become a file before anything can show them. Three different stores end up +holding them, with three different owners and three different rules. Getting the tier wrong is how +you either leak disk forever or delete something the user was still looking at; both have happened. + +| Tier | Where | Owner | Rule | +| ---------------------- | --------------------------------------- | ------ | -------------------------------------------------------------------------- | +| **Materialized image** | `$OTTO_HOME/attachments/.` | daemon | The record. Aged out by policy, never by reference count. | +| **Preview attachment** | app attachment store, id `preview_*` | app | A cache of the tier above. Pinned while live, collected when it is not. | +| **Sent attachment** | app attachment store, id `att_*` | app | The user's own content. Referenced by drafts and messages. Never aged out. | + +## Tier 1 — materialized images are the record, not a cache + +`materializeProviderImage` (`packages/server/src/server/agent/providers/provider-image-output.ts`) +writes the bytes and returns a path, and the provider emits `![alt](file:///…)` into the timeline. +That markdown is persisted. Every later render of that message — tomorrow, after a daemon restart — +reads the file back. **Nothing regenerates it.** The tool call that produced it is long finished. + +So it is not a cache, and the two properties it needs are the two the old implementation broke: + +- **It must outlive the process.** It used to live in a per-daemon-start `mkdtemp` under the OS temp + directory. Nothing ever removed those directories, so they accumulated one per daemon start — and + the OS removed their _contents_ on its own schedule, so screenshots disappeared from transcripts + that were still open. Unbounded growth and silent data loss, from one choice. +- **It must be bounded by something we control.** `$OTTO_HOME/attachments` is one directory, ours to + age out, next to every other piece of daemon state a user might want to reclaim. + +Filenames are the SHA-256 of the bytes, so twenty screenshots of an unchanged page are one file, and +re-materializing rewrites the same path. That rewrite is load-bearing for retention: **re-use is a +write**, so an image that keeps appearing in a live transcript keeps its mtime fresh and only +genuinely cold bytes age out. + +### The policy + +`startMaterializedImageHousekeeping` runs at daemon start and daily after that (`unref`'d — never a +reason for the process to stay alive). Two levers, in `provider-image-output.ts`: + +| Lever | Default | What it is for | +| ------------------------------------ | ------- | ------------------------------------------------------------------------ | +| `MATERIALIZED_IMAGE_MAX_AGE_MS` | 30 days | The primary rule. Cold bytes go. | +| `MATERIALIZED_IMAGE_MAX_TOTAL_BYTES` | 512 MB | Backstop for a burst the age rule has not caught up with. ~4,500 images. | + +`selectStaleMaterializedImages` (`provider-image-retention.ts`) is pure and separately tested, for +the reason `selectArchivedForDeletion` is: every name it returns is unlinked with no undo, and the +failure is silent — the transcript keeps its markdown and quietly renders alt text instead. + +**This is a deliberate exception to "nothing deletes on a timer"** ([chat-lifecycle](chat-lifecycle.md) +holds that line for chat records). A chat record is the user's writing and is irreplaceable; a +screenshot from six weeks ago is a large binary whose loss degrades one old message to alt text. The +alternative is a directory that grows forever. The numbers are set so a normal user never reaches +either bound. + +The startup pass also removes the retired `otto-attachments-*` temp directories, but only ones with +no file written in the last week — this repo runs installed and dev daemons side by side, and a +directory a live older daemon is still writing to is left alone. + +## Tier 2 — preview attachments are a cache, and must be pinned + +The app cannot render a daemon-side path directly, so it reads the file over the file RPC and +persists a local copy: `client.readFile` → `persistAttachmentFromBytes` → `useAttachmentPreviewUrl`. +That copy is a **preview attachment**, ided by `createPreviewAttachmentId`, and it is the thing three +surfaces render from — `AssistantMarkdownImage` in chat, the workspace-image path in the markdown +viewer, and the file-tab image preview. + +The draft store's GC (`runAttachmentGc`) owns the whole app attachment store and deletes anything it +cannot trace to a live reference: drafts, queued messages, pending creates, the live stream, the +workspace attachment store. **A preview attachment hangs off none of them.** It ran on every draft +save, so a keystroke deleted every screenshot in the transcript and the chat rendered "Unable to load +image preview" from then on. + +`attachments/preview-pins.ts` is the fix. Minting a preview id pins it — inside +`createPreviewAttachmentId`, before the bytes are written, so no file ever exists unpinned — and the +GC counts pinned ids as referenced. The pin set is capped at 512 with the least recently minted +falling out first, so a long desktop session cannot grow the store without bound; React Query has +dropped the matching metadata by then, so an image that far back in the scrollback refetches and +re-pins when it is next rendered. + +Deleting a preview attachment is always safe in principle — tier 1 can regenerate it — which is +exactly why the pin is a cap and not a permanent reference. + +**The rule for the next feature that persists an attachment nobody sends:** if it does not hang off a +draft, a queued message or the workspace attachment store, it needs a reference in `runAttachmentGc` +or it will be deleted, quickly and silently. + +## Tier 3 — sent attachments + +`att_*` ids: images and files the user attached to a message. Referenced by the draft that holds +them and by the stream item once sent. Never swept by age or size — this is user content, and the +rule that governs it is the chat's, not this page's. + +## The Storage section + +Settings → a host → **Storage** is where a person sees and reclaims all of this. One row per store, +because "images: 812 MB" is a useless number when half of it is a cache you cannot lose and half is a +record you can: + +| Row | Backed by | Clearing it | +| ---------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------- | +| Images on this host | `attachments.images.*` RPCs | Dry run, then a destructive confirm quoting the real count and size. Not undoable. | +| Cached copies on this device | the app store's `usage()` / `clearPreviews()` | A plain action. Every byte is a copy of the row above, so the worst case is a refetch. | + +Sent attachments are shown only as context inside the preview row's hint — their size, and the fact +that clearing never touches them. Offering to sweep the user's own content from a disk-space screen +would be the wrong default even with a confirm. + +**The clear RPC follows `history.agents.clear_archived` exactly**: `dryRun: true` by default, +`olderThanDays: 0` meaning _everything_. That last point is a real trap, and why +`selectMaterializedImagesToClear` is a separate function from `selectStaleMaterializedImages` rather +than a parameterization: to the background sweep an age of zero **disables** the age rule, and to a +person who pressed "Clear" it means **take everything**. One function serving both readings is how +that becomes a data-loss bug. + +`maxAgeDays` and `maxTotalMb` are editable in the same section and persist to daemon config. The +housekeeping reads them fresh on every pass, so an edit lands on the next sweep rather than at the +next daemon restart. + +Gated by `server_info.features.attachmentStorage` (`COMPAT(attachmentStorage)`, v0.7.1). Per the +feature contract there is no fallback: an old daemon simply does not get the host half. The +device-local row still renders, because it needs no daemon at all. + +## Deliberately not built + +**Per-chat and per-workspace reclaim.** Filenames are a content hash, so the same bytes may be +referenced from three transcripts; "this workspace's images" is an ownership that does not exist and +would have to be invented — an index maintained at materialize time, kept honest across chat delete +and workspace archive. Age plus a global clear carries the feature. Revisit only if someone asks for +scope, and see `projects/README.md` before starting. diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md new file mode 100644 index 000000000..55427ffb4 --- /dev/null +++ b/docs/browser-capture-harness.md @@ -0,0 +1,69 @@ +# Browser Capture Harness + +The desktop capture harness is the real-Electron verification path for browser screenshots. +It validates the compositor behavior that unit tests cannot see: + +- the resident automation `` starts in the production parking state; +- the parked guest remains paintable and has a copyable viewport frame; +- the resident webview guest is sized to 1280x800 logical pixels; +- multiple resident webviews are parked as an overlapping stack without per-capture + stacking changes; +- a newly attached resident webview whose first useful frame is delayed can be captured + by retrying until the frame appears; +- both viewport `capturePage` and full-page CDP screenshots return real pixels from + the permanent production parking state; +- guest background throttling can be disabled once at attach without per-capture + renderer coordination. + +Run it with the repo Electron: + +```bash +npm run capture-harness --workspace=@otto-code/desktop +``` + +Run the browser automation fixture with: + +```bash +OTTO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@otto-code/desktop +``` + +The automation group uses a real guest webview to verify the page-side ref contract: +ARIA-like snapshot text includes headings, static text, and controls; refs survive +`pushState` when the element still matches; same-URL rerenders stale old refs; and a +file-input ref can be resolved to a CDP backend node id for upload. It also verifies +page-context evaluation, including passing a resolved ref element as the function argument. + +On macOS the harness process must set `app.setActivationPolicy("accessory")` and +hide the Dock icon before creating any window. `showInactive()` only prevents window +focus; a normal Electron app launch can still activate the app and steal focus. +Harness windows are then created hidden, positioned in a screen corner, skipped from +the taskbar where Electron supports it, and revealed with `showInactive()` from +`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`: +the compositor only needs visible inactive windows, and harness runs must not steal +focus from the person using the machine. + +The harness writes PNG evidence and `results.json` to: + +```text +packages/desktop/capture-harness/out/ +``` + +A passing run prints `PASS` lines for the production P1 attach-off parking state, +including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The +PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport +is usually saved as 2560x1600. + +## Mechanism + +Electron captures copy from the guest web contents' compositor surface. A resident +webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its +copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`, +`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`. +The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely +overlap at `left:0`, `top:0`. + +There is no renderer prep/restore handshake. Main disables guest background throttling +once when the webview attaches, then screenshot capture uses the shared serialized queue, +invalidates before each attempt, and retries known first-frame failures within the +5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`; +full-page screenshots use the existing CDP path with layout metrics and screenshot clip. diff --git a/docs/changes-view.md b/docs/changes-view.md new file mode 100644 index 000000000..95a9f8e9f --- /dev/null +++ b/docs/changes-view.md @@ -0,0 +1,221 @@ +# Changes view — what the diff is measured against + +The Changes tab has two modes. **Uncommitted** diffs the working tree against `HEAD` and needs +no base. **Committed** diffs against a _base branch_, and everything below is about how that +base is chosen. Get it wrong and the view fills with commits the user never wrote — which is +the single most common way this feature stops being usable. + +The engine is `packages/server/src/utils/checkout-git.ts`. Per-worktree state lives in +`/otto/worktree.json` (`packages/server/src/utils/worktree-metadata.ts`), and the +per-branch base lives beside it in `/otto/diff-base.json` +(`packages/server/src/utils/checkout-diff-base-store.ts`). + +## The resolution ladder + +`resolveBaseRefLadder` is the **single** answer to "what is this diffed against?". It used to be +computed in two places; that is exactly how you get two different answers to the same question, so +`resolveBaseRefForCwd` and `getCheckoutSnapshotFacts` both call the one ladder now. + +1. **The branch's remembered base** — `diff-base.json`, keyed by branch name. Either a user pick + or a parent detected earlier. **Sticky by design**: see below. +2. **An Otto worktree's creation-time base** — the `baseRefName` in `worktree.json`, which already + records the branch the worktree was cut from. +3. **The inferred parent branch** (`checkout-git-parent-branch.ts`), then written to + `diff-base.json` so step 1 answers from here on. +4. **The repository default branch** — `resolveRepositoryDefaultBranch`, which reads + `refs/remotes/origin/HEAD` and prefers the local branch name over the remote-tracking one. +5. **`origin/` when steps 1-4 land on the branch you are standing on.** On the default + branch `merge-base(main, HEAD)` is HEAD, so "vs main" is empty by definition. Comparing against + the remote-tracking ref shows unpushed local commits, which is the only useful answer there. + +Then, as before: + +- **Local ref or remote-tracking ref?** `resolveBestComparisonBaseRef` picks between `` and + `origin/` — unless the base is remote-qualified, which is a pin it honours verbatim. +- **Which commit?** `git merge-base HEAD`. The diff is `merge-base..HEAD`, so commits + that are only on the base branch never appear. + +Everything that measures against the base — the diff (`getCheckoutDiff`), ahead/behind +(`getAheadBehind`), and the shortstat badge (`getCheckoutShortstat`) — funnels through the +comparison step, either directly or via the cached `comparisonBaseRef` on +`getCheckoutSnapshotFacts`. Keep it that way: three copies of this logic is three different answers +to "what changed?". + +**That means every diff-derived number in the UI moves together**, and there are only two places in +the server that count anything (the `--numstat` in `getCheckoutDiff` and the `--shortstat` in +`getCheckoutShortstat`). So the `+N/-N` chip on sidebar workspace rows (`diffStat` on the workspace +snapshot, rendered by `sidebar-workspace-row.tsx` / `sidebar-status-list.tsx`), the ahead/behind +counts behind the git actions, and the Changes list all report against the same base. When detection +repoints a stacked branch at its parent, all of them shrink to that branch's own work in the same +pass — there is no surface that keeps counting against the default branch. There is a regression +test asserting the badge and ahead/behind specifically, not just the diff, because they are the +numbers users notice first. + +One pre-existing subtlety worth knowing when reading the badge: `getCheckoutShortstat` diffs the +**working tree** against the merge-base and adds untracked lines, so it is "everything since the +fork point, committed or not" — not the committed diff alone. + +## Parent detection is a heuristic, and has to look like one + +**Git does not record a branch's parent.** There is no field to read, so +`inferParentBranchRef` reconstructs it from the commit graph: enumerate up to 50 recent local and +`origin/` refs, `merge-base` each against HEAD, drop candidates whose merge-base _is_ HEAD (those +are children, not parents), and take the candidate whose merge-base is the **latest** commit. That +commit is the nearest branch point, so the fewest commits the branch did not author leak in. + +Four things about this are load-bearing, and each has a regression test: + +- **The default branch has no parent, and must not be asked.** This is the one that bit in + practice: on `main` the scan proposed a long-merged feature branch. Every branch ever merged into + `main` has a tip that is an ancestor of HEAD, which is **graph-identical to a stacked parent** — + and since `main` is excluded from its own candidate list, a merged branch always wins. So + `currentBranch === defaultBranch` returns `null` up front and the ladder falls to step 5. + Off the default branch the ambiguity resolves itself: once a branch merges, the default branch's + own fork point with HEAD is _later_ than the merged branch's tip, so the default branch wins. +- **Ancestry, not dates.** Ordering uses `merge-base --is-ancestor`, because commit timestamps are + rebase- and clock-controlled and routinely disagree with topology. Fork points are all ancestors + of HEAD but form a DAG rather than a chain (any merge in HEAD's history splits them), so the scan + repeats until it stops moving instead of trusting one greedy pass. +- **It reports a branch, not a ref.** A winning `origin/X` collapses to `X` when a local branch of + that name exists. Otherwise the chip would read "vs origin/main" for someone who simply branched + off `main`, and the comparison step already picks the right side. The qualifier survives only for + a parent that exists _solely_ on origin, and for an explicit user pin. +- **`%(refname:short)` renders `refs/remotes/origin/HEAD` as bare `origin`**, which reads as an + ordinary branch and beats real candidates on merge-base. Enumeration uses full `%(refname)` and + shortens explicitly to keep that case identifiable. + +The merge-base probes run through a bounded pool (`MERGE_BASE_CONCURRENCY`), because a git +subprocess costs ~60ms on Windows and 50 serial probes is measurably seconds. Diverged candidates +cannot be skipped: a parent that gained commits after you branched off it is diverged from you, and +that is the common case, not an edge case. + +Detection is **sticky**: the first computed answer is written down and never recomputed, including +when it came from the step-4/5 fallback rather than the graph. Two reasons. A heuristic that +silently re-decides itself every read is worse than no heuristic, because the base moves under the +user between two views of the same branch. And without persisting the negative case, the 50-candidate +scan would re-run on every snapshot refresh for every branch with no detectable parent — the default +branch most of all, which is where most sessions sit. + +Because sticky makes a wrong guess _persistent_, the provenance is on the wire (`baseSource`: +`user` / `inferred` / `worktree` / `default`) and the chip says which. The picker's **"Detect parent +branch"** row (`redetect` on the RPC) clears the stored entry and runs the ladder again. Treat those +two as part of the feature, not polish: they are what makes a heuristic acceptable. + +**Self-healing.** If a stored base names a branch that no longer resolves on either side — the +ordinary case where a parent merges and someone deletes it — the entry re-resolves **once** to the +repository default and is rewritten. Guarded on the current branch itself resolving, so a repo +mid-fetch or mid-clone does not trade a good base for the default permanently. + +Because a detected base is this daemon's guess rather than a user contract, the `Base ref mismatch` +rejection below applies only when `baseSource` is `user` or `worktree`. An ad-hoc `compare.baseRef` +may override a guess; overriding an explicit choice still fails loudly. + +## Local vs origin: pick the later fork point, not the fresher ref + +`` and `origin/` routinely disagree. Local can be behind origin (nobody pulled), +ahead of it (nobody pushed), or the two can have diverged outright. Otto never fetches on the +read path, so it works with whatever refs the repo already has. + +The rule is **not** "prefer origin" and **not** "prefer the fresher branch". It is: compute +`merge-base` with `HEAD` for both candidates and take the candidate whose merge-base is the +_later_ commit (`resolveLatestForkPointBaseRef` — ordering via `git merge-base --is-ancestor`). +That commit is the real branch point, so nothing the branch didn't author can leak in. + +Only when both candidates fork at the same commit — where the choice cannot change the diff — +does it fall back to `pickMoreAdvancedBaseRef`, which prefers whichever ref carries more +commits the other lacks. That fallback exists for the ahead/behind counts, which _do_ want the +fresher ref so "behind by N" reflects reality. + +Merge and pull targets deliberately do not use this. `resolveMostAheadBaseRef` (used by +`mergeFromBase`) always wants the freshest ref, because merging into a stale one silently drops +the other side's commits. + +**Known gap:** if `origin/` does not exist at all — never fetched, or a `remote.origin.fetch` +refspec that excludes it — there is only one candidate and merge-base math cannot help. A stale +base ref that nobody updates stays stale. Auto-fetching on workspace open is the obvious fix and +is deliberately not built: it puts network traffic on a read-only view. + +## Base override (stacked branches) + +"Diff against the default branch" is the wrong question for a stacked branch. If `child` sits on +top of `parent`, the parent's commits are between the default branch and `child`'s HEAD, so they +show up inside the child's Changes view as if the user wrote them. A forge PR gets this right +because it carries an explicit base; `worktree.baseRef.set.request` makes that local. Detection +now handles the common case automatically, and this is the override when it guesses wrong or you +want a different comparison. + +- `setCheckoutBaseRef(cwd, baseRef | null, context, { redetect })` validates the branch exists + locally or on origin, refuses the branch you are on, and records the pick. `null` pins the + repository default; `redetect` forgets the pick and re-runs the ladder. +- **Any git checkout, not just Otto worktrees.** The base is stored per branch in + `diff-base.json`, which is what makes this work outside a worktree: a plain checkout's gitdir is + shared by _every_ branch you check out, so a single scalar base would bleed one branch's + comparison onto the next branch you switch to. An Otto worktree additionally keeps writing + `worktree.json`, since that record is what merge and PR creation read. +- At creation, a `branch-off` worktree already records the base branch the user picked, so + cutting a worktree from a parent branch stacks correctly with no extra step. + +### `main` and `origin/main` are separate choices + +The two disagree whenever local is behind, ahead of, or diverged from origin, so the picker offers +both rows and a remote-qualified pick is stored **with** its qualifier +(`validateBaseRefNameAllowingRemote`). `resolveBestComparisonBaseRef` then honours it verbatim +instead of re-picking a side by fork point: an explicit pin beats the heuristic. A bare name keeps +the auto-pick behaviour exactly as before. + +**This deliberately splits the "one source of truth" rule, and the split is the point.** The +remote qualifier is _comparison-only_. Merge and PR targets collapse back to the local branch name +(`mergeToBase` normalizes, `resolveMostAheadBaseRef` wants the freshest ref, and an Otto worktree's +`worktree.json` always stores the local name) because **there is no such thing as opening a pull +request against a remote-tracking ref**. If you find that asymmetry and think it is a bug, it is +not — deleting it breaks PR creation for anyone who pinned `origin/`. + +Otherwise the stored base remains one source of truth: `mergeToBase` and `createPullRequest` read +the same ladder, so repointing a stacked branch at its parent also makes "Create PR" target the +parent (the Bitbucket behavior). Clients echo the base back on `compare.baseRef` and PR creation, +and the daemon rejects a mismatch with `Base ref mismatch` — but only for a base someone actually +chose (`baseSource` of `user` or `worktree`), per the ladder section above. + +Gated by `server_info.features.worktreeDiffBase` (`COMPAT(worktreeDiffBase)`, added v0.6.8). +Repointing a plain checkout, detection, the `origin/` pin and the re-detect action additionally need +`server_info.features.checkoutDiffBaseAnyRepo` (`COMPAT(checkoutDiffBaseAnyRepo)`, added v0.7.4). +Without them the client shows the label and hides the picker — there is no client-side fallback, +since only the daemon can write the stored base. + +## UI + +`packages/app/src/git/diff-base-switcher.tsx` renders the `vs ` chip beside the diff-mode +dropdown in the Changes toolbar, visible only in Committed mode. Naming the base is half the +value on its own: before this existed, the view never said what it was comparing against. + +Capability detection lives in one place in that file (`checkoutQualifies`), per the feature-contract +rule — downstream code reads a single boolean rather than branching on daemon version. Branch rows +come from `getBranchSuggestions`, whose `branchDetails` already carry `hasLocal` / `hasRemote`, which +is what lets the picker render `main` and `origin/main` as separate rows without a new RPC. + +## Crossing between Files and Changes + +The two directions are symmetric, and both go through ephemeral request slots on the panel store +rather than through props — the destination pane is usually not mounted when the request is made, +since the explorer shows one tab at a time. + +| Direction | Producer | Slot | Consumer | +| --------------- | --------------------------------------------------------------- | ---------------------- | ----------------------------------- | +| Changes → Files | "Find in files" in the diff row's context menu | `filesRevealRequest` | `components/file-explorer-pane.tsx` | +| Files → Changes | "View changes" in the Files tree menus and the file tab toolbar | `changesRevealRequest` | `git/diff-pane.tsx` | + +Each producer stashes the path, then switches the explorer tab; the consumer clears the slot on +mount and works toward the target. Revealing in Changes is a small state machine, not one shot: +the diff is usually still loading, so each pass un-collapses a blocking ancestor folder, expands +the file's body, or scrolls to its header, writing store state and re-running until the header is +reachable. The scroll then re-asserts once after 100ms, because rows above the target are +estimated heights until they render and measure themselves. + +**"View changes" is offered only for files actually in the diff**, so it can never land on a tab +that does not list the file. There is no lightweight "which paths changed" RPC — the daemon serves +the whole diff or nothing — so `git/changes-reveal.ts` mounts the _same_ `useCheckoutDiffQuery` +the Changes pane mounts, deriving mode/baseRef/ignoreWhitespace identically so both resolve to one +query key: one cache entry, one daemon subscription however many surfaces ask. The cost is that an +open file tab or the Files tab now keeps that subscription alive, where before only the Changes +tab did. If you change how `GitDiffPane` derives those three parameters, change `changes-reveal.ts` +to match or the sharing silently becomes a second subscription. diff --git a/docs/chat-lifecycle.md b/docs/chat-lifecycle.md new file mode 100644 index 000000000..4e4f72b00 --- /dev/null +++ b/docs/chat-lifecycle.md @@ -0,0 +1,356 @@ +# Chat lifecycle + +How a chat is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track). + +**Chat vs agent.** A **chat** is the wrapper you interact with and view through — the durable conversation surface with a tab, a title, a timeline, and an archive gesture. An **agent** is the AI session running inside it: one provider, one model, one effort, one running process. You archive a chat; you stop an agent. This doc is about the chat. + +Code identifiers and wire names stay historical and are **not** renamed — `AgentManager`, `agentId`, `AgentSnapshotPayload`, `otto.parent-agent-id`, `create_agent`, `archiveAgent`. Only human-facing language distinguishes the two. Workspaces and worktrees are a separate concern with their own lifecycle — see [workspace-lifecycle.md](workspace-lifecycle.md); the only coupling is that archiving a workspace archives the chats it owns. + +## States + +``` +initializing → idle → running → idle (or error → closed) + ↑ │ + └────────┘ (the agent completes a turn, awaits next prompt) +``` + +Each chat in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`, reflecting the state of the agent session behind it. State transitions persist to disk and stream to subscribed clients via WebSocket. + +## Delivery — how a prompt reaches a busy agent + +Every prompt entrypoint (composer, MCP `send_agent_prompt`, CLI, chat mentions, schedule fires, notify-on-finish) funnels through `sendPromptToAgent` → `startAgentRun`, which takes a `delivery` mode. It only matters when the target is **busy**; against an idle agent both modes run the prompt immediately. + +| `delivery` | Busy target | +| --------------------- | ------------------------------------------------------------------------ | +| `interrupt` (default) | Cancel the in-flight turn, run this now **in the same provider session** | +| `queue` | Let the turn finish; run this as the next turn | + +`interrupt` is the wire default. UI label: **Interrupt** / **Queue** (Settings → Default send, and the composer's Queue track). `cancel_agent` remains interrupt-only — abort the run, keep the agent alive. + +The whole feature lives in the turn lifecycle **above** every provider adapter, so it behaves identically for Claude, Codex, Copilot, OpenCode, Pi, and the openai-compatible provider. There are no per-provider adapters. + +### Which delivery each entrypoint picks + +The default is the wire default, not the right answer for every sender. A person typing into the composer has decided to interrupt by typing; Otto injecting a message on someone's behalf has decided nothing. + +| Entrypoint | Delivery | Why | +| -------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Composer, CLI `otto agent send` | user's choice | Settings → Default send; the alternate send action does the other one | +| MCP `send_agent_prompt` | caller's choice | Explicit `delivery` arg, `interrupt` default — the calling agent knows whether its prompt is a correction or a follow-up | +| **Chat @mention** | **`queue`** | A room mention is a message, not an emergency. Interrupting discarded work on behalf of someone who only meant to say something — and `@everyone` did it to a roomful at once | +| **Notify-on-finish** | **`queue`** | "Your child finished" is a report. Interrupting killed the turn the parent ran while the child worked, and a fan-out of N children interrupted it N times in a row | +| Schedule fire → existing chat | neither (**fails**) | `executeSchedule` pre-checks `hasInFlightRun` and fails the run with "already has an active run"; it has never interrupted. Open question — see below | +| Schedule fire → new chat, `/loop` iterations | n/a | Each runs a freshly created agent, which cannot be busy | + +Both flipped paths already carried `source: "system"`, so each injected message still arrives as **its own turn** — queueing changes when a mention or a report lands, never how many there are or what they say. (The mention path was untagged until the flip; tagging it was part of the change, because two mentions merging into one turn would have lost each one's envelope.) + +`delivery: "queue"` can never be the reason a prompt fails to arrive: an agent with no live session cannot be busy, so `enqueueSteerMessage` reports "not queued" and the caller dispatches normally. That matters most for exactly these senders, whose target may be closed or not yet revived. + +**Still open — a schedule firing into a chat that is already busy.** Unlike the other two this is not a delivery-flag flip: `executeSchedule` runs the prompt through `runAgent`, which blocks to collect the timeline and final text for the run record, and the steer queue's dispatch is fire-and-forget. It is also a product question rather than a correctness one — whether a schedule is a _deadline_ ("run at 09:00, or not at all", which the cadence/next-run UI implies) or a _task_ ("run this, whenever you can"). Queueing means a run record sits `running` for an unbounded time, possibly past its own next fire. The candidates are: keep fail-fast; queue with a bounded wait; or record the run as **skipped** rather than failed, which fixes the misleading part of today's behavior without changing when anything runs. Decide it as a schedule question, not a queue one. + +### How the queue drains + +- **State.** `ManagedAgent.steerQueue` is a FIFO of `SteerQueueEntry` (`steer-queue-state.ts` owns the pure logic). Ephemeral by design — a queued nudge is about the run in progress, so it does not survive a daemon restart. +- **Enqueue.** `AgentManager.enqueueSteerMessage` does the busy check and the push in one synchronous block and reports whether it took the prompt, so the answer cannot go stale between them. Not busy ⇒ the caller dispatches normally. +- **Drain.** `finalizeForegroundTurn` decides synchronously, before it emits state, whether to pop a batch instead of going idle. `pendingSteerDrain` then holds the agent visibly `running` across the async handoff — mirroring `pendingReplacement` — so the row never flickers idle→running between queued turns and a message sent in that window is buffered rather than raced into a second concurrent turn. +- **Terminal error or `closed`.** Do **not** drain. A queued turn must never run unprompted into a broken session: the queue is held and stays visible so the supervisor decides. +- **Cancel holds it, and never clears it.** `cancelAgentRunCommand` (the shared verb behind the client's stop button, ESC, and the `cancel_agent` tool) calls `holdSteerQueue`, which sets `steerQueueHeld` so the cancelled turn's finalize skips the drain. Nothing new starts by itself, and the entries survive: the Queue track keeps rendering them with their edit and send-now actions, so stopping the run is how you make room for the messages you already typed. The hold covers that one finalize only; `streamAgent` clears it, so the queue drains behind the next turn like any other. Cancel used to empty the queue on a "stop everything" reading, which threw away work the user had typed. Interrupt-and-steer never cleared it either: those queued notes are separate instructions. Only `agent.queue.clear` (and closing the agent, which has no next turn to hand anything to) empties it. + +### Several messages queued at once merge into one turn + +Consecutive **user** messages in the queue are delivered as a **single** turn, joined in FIFO order with a blank line between them; images and attachments concatenate in the same order and the head entry's `runOptions` (including `messageId`) win. + +Three notes dropped while an agent grinds through a refactor are one instruction set, not three turns. Delivering them separately makes the agent act on note 1 before it has seen the constraint in note 3, and pays a full context re-send per turn. System-injected entries (`source: "system"` — mentions, schedule fires, notify-on-finish, agent-to-agent sends) never merge: each carries its own envelope and means something on its own. + +### Editing the queue + +Order is what a FIFO means, so the queue supports three edits: **take one back** (`agent.queue.remove`, behind the Queue track's edit and send-now actions), **re-order** (`agent.queue.reorder`), and **drop everything** (`agent.queue.clear`). + +Re-order is exposed as per-row **move earlier / move later** controls rather than drag-and-drop. The track is a two-to-three-row stack pinned above the composer on phone, tablet, and web; a drag gesture there competes with the scroll and the keyboard for a list that is almost never long enough to need one, and buttons are the affordance that works identically on all three. Both are complete — any order is reachable either way. + +The two arrows are stacked in a half-height column rather than sitting side by side as full-size round buttons: the pair then reads as one order control instead of two actions competing with edit and send-now, and the column stacks to the same height as the round buttons beside it so the row does not grow. Rows at the ends of the queue keep the arrow they cannot use, rendered disabled, so every row's controls stay on the same grid. + +**Send all** rides on the **head row only**, and only when more than one message waits. It runs the whole queue now, as one turn, which is exactly what the drain would do when the turn in flight ends; the client takes every entry back in order and joins them the same way `mergeSteerQueueBatch` does, so "Send all" and a natural drain produce the identical prompt. It confirms the interrupt first when the agent is running, like send-now does, and entries the drain beat it to are simply skipped. A single queued message needs no such button because its own send-now already is one. + +The daemon re-resolves the entry by id and **clamps** the destination rather than rejecting it, because the client is rendering a snapshot that may already be one drain stale; a move that lands at the end of a shorter queue is what the user meant. `moved: false` (already drained, or already there) is not an error — the authoritative order arrives on the agent snapshot regardless. + +### Interrupts the provider did not fully honour + +Claude Agent SDK ≥ 0.3.212 resolves `query.interrupt()` with an **interrupt receipt** (`still_queued`, feature-detected via `interrupt_receipt_v1` in `system/init`): uuids of async user messages that survive the interrupt and will still run. The Claude adapter filters it to uuids **it stamped itself**, since the receipt also carries ids the CLI enqueued (cron triggers, auto-resume continuations) that a client is told to ignore rather than treat as errors. Anything left means an interrupt Otto reported as complete left one of Otto's own messages live in the CLI, and it is logged at `warn` as `provider.claude.interrupt.still_queued`. + +The reconcile is deliberately **diagnostic, not corrective**, and that follows from the queue's shape: Otto's queue is daemon-owned and sits above every adapter, so there is no provider-side queue for the daemon to re-sync against — the daemon already decides what runs next for every provider. The SDK also exposes no `cancel_async_message` on `Query`, so a survivor could not be withdrawn even if there were something to repair. Revisit if that lands. + +### Wire surface + +`server_info.features.steerQueue` gates the daemon-owned queue (`COMPAT(steerQueue)`, v0.6.8); `features.steerQueueReorder` gates re-ordering separately (`COMPAT(steerQueueReorder)`, v0.6.9), because a 0.6.8 daemon owns a queue it cannot re-order — against one, the move controls are simply absent and the rest of the queue works. With it the client sends `delivery: "queue"` on `send_agent_message_request`, reads `AgentSnapshotPayload.queuedMessages` (id + truncated preview + `enqueuedAt`), and edits the queue via `agent.queue.remove` / `agent.queue.reorder` / `agent.queue.clear`. Without it the composer keeps its own client-held queue drained on the running→idle edge — the behavior Otto has always had, not a degraded build of the daemon feature. Attachments live only in the client, so the daemon-backed path keeps a local sidecar keyed by the daemon's entry id purely so "edit" can restore them; losing it costs the attachments on edit, nothing else. + +## Typed text is never destroyed + +Nothing Otto does to a composer may discard what the user typed. Not Escape, not dismissing a popover, not a keyboard action of any kind. The only thing that empties the box is sending or queueing the message, and that text lands on the Up-arrow history stack on its way out. + +The reason is that there is no undo for it. The composer is a controlled value with no edit history, and clearing it also clears the persisted draft (`useAgentInputDraft` writes through to the draft store on every change, and empty text is stored as "abandoned"). One keystroke therefore destroys the text on screen, the copy on disk, and any way of getting either back. A long unsent prompt is often the most expensive thing on the screen. + +Two mechanisms enforce this: + +- `dispatchComposerKeyboardAction` (`packages/app/src/composer/keyboard-actions.ts`) has no access to the composer text at all. Escape cancels dictation, then the running agent, and stops there. `keyboard-actions.test.ts` asserts the dispatcher never asks the input to do anything but cancel dictation. +- The autocomplete popover dismisses **itself** on Escape (`useAgentAutocomplete`), pinned to the `/command` or `@mention` token under the caret so it stays dismissed while the user keeps typing that token. It used to answer Escape by clearing the whole input. + +Clearing the composer is a user action (select-all, delete), never Otto's. + +## Relationships + +A chat's agent can launch other chats via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions: + +- `relationship` decides whether the new chat belongs under the caller. +- `workspace` decides where the new chat lives and whether a new workspace/worktree is created. + +`relationship: { kind: "subagent" }` stamps the created chat with `otto.parent-agent-id`, pointing back at the creating chat. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session. + +`relationship: { kind: "detached" }` creates a sibling/root chat (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating chat for cwd/config inheritance, but it does not write `otto.parent-agent-id`. + +- **Subagents** — exist as part of the creating chat's work, appear in that chat's subagents track, and are archived with it. +- **Detached chats** — stand on their own, do not appear in the creating chat's subagents track, and are not archived with it. + +`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new chat; worktree creation goes through the Otto worktree workflow and stamps the chat with that fresh workspace id. + +Users can also detach an existing subagent from the subagents track. Detach removes the `otto.parent-agent-id` label only: it does not stop, archive, move, or restart anything. The chat keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root chat for tab close, workspace activity, and future parent archive. + +`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating chat. Set it to `false` only for truly fire-and-forget chats or prompts. + +## Moving a chat to another workspace + +"Move to workspace" on a chat tab re-stamps which workspace owns the chat, via `agent.workspace.transfer`. Gated by `server_info.features.agentWorkspaceTransfer`. + +Ownership is the single `workspaceId` field. Agent state on disk is keyed by agent id, the timeline store is keyed by agent id, and clients decide which workspace shows a chat in exactly one place (`agentBelongsToWorkspace` in `workspace-tabs/agent-visibility.ts`). So a move is one field write plus a broadcast: the tab appears in the target and prunes from the source on every connected client, with nothing to migrate alongside it. + +**The move does not change `cwd`, and the target does not have to be over the chat's directory.** `cwd` answers "where does it run", `workspaceId` answers "which workspace owns it", and the daemon has never required them to agree: a chat's cwd can already be a subdirectory of its workspace, and nothing validates one against the other. A moved chat keeps running where it was started, which is the only option that is true to a provider session already rooted on disk. That is also why any workspace is a valid destination, in the same project or a different one. + +The consequence worth knowing: after a cross-directory move, the destination workspace's Changes view reflects that workspace's directory, not the chat's. The chat is a conversation the workspace shows, not a thing bound to its checkout. + +The daemon refuses only destinations that are not real: a workspace that does not exist, one that is archived, and hidden schedule-run workspaces (a chat moved into one would be stranded where no client lists it). Moving a chat to the workspace it already lives in succeeds as a no-op rather than erroring, so two clients racing the same move do not produce a spurious failure. + +## Archive + +Archive is a **soft delete**: the chat record stays on disk with `archivedAt` set, the runtime is closed, and the chat disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. The hard counterpart is [Delete](#delete), which archive is the required first step for. + +`create_agent_request` can opt a chat into `autoArchive`. In that mode the daemon archives the chat after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created an Otto worktree through its `worktree` field, auto-archive archives that worktree too, which removes the chat records inside the worktree. + +Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`): + +1. Snapshot the current session into the registry +2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing` +3. Notify subscribers +4. Close the runtime (kills the agent process if still running) +5. **Cascade-archive children** — any chat whose `otto.parent-agent-id` label matches the archived chat gets archived too, recursively + +Cascade is what keeps subagent fleets from outliving their orchestrator. + +## Delete + +Archive is a soft delete. **Delete** is the hard one, and it is the counterpart archive spent a long time without: `archivedAt` got set, the record stayed on disk forever, and no app surface could remove it. + +### What delete removes, and what it deliberately does not + +Deleting a chat removes **Otto's record of it** — the JSON at `$OTTO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`, its committed timeline, and the row from every list. That is the whole of it. + +It does **not** remove the provider's own transcript. Claude's `/projects//.jsonl` and its sibling `/` subagent tree, Codex's threads, OpenCode's sessions — those stay exactly where the provider wrote them. Three reasons, in order of weight: + +1. **They are not Otto's files.** Otto never created them; it holds a `persistence.sessionId` pointing at them. +2. **Another tool still reads them.** `claude --resume` resolves that path. Silently deleting another tool's state is the kind of thing that costs trust once and permanently. +3. **Leaving them is the recoverable option.** A user who deletes a chat by mistake still has the conversation. + +An **opt-in switch** to also delete provider data was considered and **rejected** — the product owner's call, 2026-07-25 ("that seems dangerous grounds"). There is deliberately no wire field for it, no setting, and no disabled placeholder. If a future version wants it, that is a decision to retake with fresh eyes, not a hook to leave lying around. + +### Which means the UI has to say so + +Reason 3 is worthless if nobody knows the data is there, so **the confirm dialog names where the conversation survives** — "Claude Code's own transcript on the host is left in place, so the conversation itself stays on disk and can still be read or resumed outside Otto" — and scopes its irreversibility claim honestly: _Otto's side_ of this can't be undone. Copy lives in `packages/app/src/history/delete-dialogs.ts`, pure and asserted on, because this is the one place where getting the wording wrong is the same as lying. + +Bulk clear repeats the same disclosure rather than assuming it was read once. Clearing many at a time must not become a back door that deletes provider data in aggregate. + +### Reaching it + +**Delete is unreachable for a chat that has not been archived first.** Two-step destruction is the friction this deserves, and it keeps the history list's fast-path archive gesture intact. + +| Surface | Gesture | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| History list (`agent-list`) | Long-press an **archived** row → destructive confirm → delete. Long-pressing an unarchived row still archives, as it always has | +| History screen | An **All / Active / Archived** filter, plus **Clear archived** — dry run, confirm with the real count, then sweep | +| CLI | `otto agent delete ` (works on archived rows already); `--archived` / `--include-archived` widen the bulk `--all` / `--cwd` filters | + +Long-press on an archived row used to re-archive it — a no-op that read as a broken gesture. That is the seam delete took over. + +The CLI's bare `--all` and `--cwd` still mean **active only**, unchanged, so nobody's muscle memory becomes destructive. `--archived` and `--include-archived` together is refused rather than guessed. + +### Wire surface + +| RPC | Shape | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `delete_agent_request` → `agent_deleted` | Existing flat name, one chat. Kept as-is for back-compat | +| `history.agents.clear_archived.request` | `{ olderThanDays = 0, dryRun = true }` → `{ matched, deleted, failed, agentIds, dryRun, error }` | + +Both are gated by `server_info.features.historyDelete` (`COMPAT(historyDelete)`, v0.7.0). Per the feature contract there is no fallback path: against an older daemon the client says "Update the host" instead of simulating delete. Archive keeps working on every daemon — that is the protocol contract, not a degraded build of this feature. + +Three shape decisions worth keeping: + +- **Bulk clear is server-side, necessarily.** The history list is cursor-paginated across hosts (`AGENT_HISTORY_PAGE_LIMIT = 200`), so the client never holds the whole archived set. A client-side loop would silently clear only the pages it happened to have fetched, which is the worst possible failure mode for a destructive action. +- **`dryRun` defaults to `true`.** A request that omits the flag previews instead of deleting. It is the one field where the safe answer and the convenient answer differ. +- **Per-item outcome is reported.** `close_items_response` silently omits failures; a destructive batch cannot, so the response carries `deleted`/`failed` counts and the ids that actually went, and the UI can say "deleted 12, 3 could not be deleted." + +### Selection is the load-bearing part + +`selectArchivedForDeletion` (`packages/server/src/server/agent/history-retention.ts`) is pure and separately tested, because it is the only part of the sweep where a boundary mistake is silent — every id it returns has its record unlinked with no undo. + +- A chat with **no `archivedAt` is never selected**, whatever the cutoff. An active chat cannot be swept. +- An **unparseable `archivedAt`** counts as archived but ageless: taken at `olderThanDays: 0`, skipped under any age cutoff, because the safe answer to "how old is this?" is to keep it. +- The cutoff is **inclusive** — archived exactly `olderThanDays` ago is that old. +- A **future** `archivedAt` (clock skew) is not older than any positive cutoff. +- Ids come back **oldest-first**, so a sweep that fails partway through has deleted the chats the user cared least about. + +Observed subagents have no stored record, so they are never swept. + +### Client-side reconcile + +`agent_deleted` always cleaned the Zustand slices thoroughly and never touched react-query, so a deleted row lingered in the history list and sidebar until a manual refresh — which reads as the delete having failed. `applyDeletedAgentResults` (`packages/app/src/history/use-delete-agent.ts`) patches the same four caches `applyArchivedAgentCloseResults` does for archive (`sidebarAgentsList`, `allAgents`, and both history keys), and the push handler calls it for the single and bulk paths alike. + +### There is no automatic retention for chat records + +Nothing deletes a chat record on a timer. Delete is always a gesture someone made. A daemon-config `historyRetentionDays` was scoped and deliberately not built — Otto should not silently delete a user's history — and the sweep RPC it would drive already exists if that changes. + +## Tabs vs archive + +These are two distinct concepts that used to be conflated: + +| Concept | Scope | Triggers | +| -------------------------- | ---------- | -------------------------- | +| **Tab** (workspace layout) | Per-client | User opens/closes a view | +| **Archive** (lifecycle) | Global | Explicit lifecycle gesture | + +Closing a tab on a **root chat** still archives — the tab is the chat's home, so closing it means "I'm done with this chat." A confirm dialog protects against archiving a chat with a running agent by accident. + +Closing a tab on a **subagent** (any chat with `parentAgentId`) is **layout-only**. The chat stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`). + +The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children. + +## The subagents track + +The collapsible track above the composer in a chat's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`): + +``` +parentAgentId === thisAgent.id AND !archivedAt +``` + +Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client. + +To keep the chat alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the chat from subagent to root/sibling from that updated snapshot. + +### Row actions, names, and cost + +Row actions are **status-aware** — the primary action matches the row's state. A running or initializing subagent shows **Stop** (transitions it to a terminal state without removing the row); a terminal subagent (`idle` after completion, `error`, or `closed`) shows **Archive** (drops it from the track). Archive is never offered while its agent is running. Stop and the pane's stop control are the _only_ callers of the stop path — tab lifecycle can never reach it, so closing a tab is always layout-only (see [Tabs vs archive](#tabs-vs-archive)). Detach stays a native-subagent-only affordance. + +**Observed subagent ids are not agents, and every lifecycle verb has to say so.** An observed row's id is a `parent::sub::key` triple: an ephemeral projection of the observed-subagent registry, with no backing `ManagedAgent` and nothing under `$OTTO_HOME/agents/**`. Every per-agent verb must special-case it explicitly, and there are **three**, not two: fetch, stop, **and archive**. Archive was the one originally missed, which made `archive_agent_request` throw "Agent not found" on an observed id and broke both the terminal row's Archive and "Clear all completed" for observed rows. `AgentManager.archiveObservedSubagent` is the path: best-effort stop, then retire the registry entry **in place**, stamped `archivedAt`, routed via `Session.archiveAgentForClose`. It is retired rather than deleted deliberately, because deleting it lets a late `task_notification` resurrect the row. **When adding any new per-agent RPC or row action, handle the observed-id path first.** + +**A native subagent going `idle` is not terminal.** `isSubagentRowTidyEligible` counts `idle` as terminal only when `attend === "observed"`. A Claude Task completion maps to idle, but a native `create_agent` subagent idles _between turns_ and must never auto-tidy out of the active list. + +**A just-stopped row stays pinned to the active list** (`partitionSubagentRows`), so the row you acted on does not jump groups under your finger. `cancel_agent_response` carries an additive `cancelled` flag, which drives a "nothing was running" toast when the stop found no live turn. + +Row names are **frozen labels**, not summaries. A short, stable name is derived once when the subagent starts (from its type, plus an optional truncated slice of the initial task) and never mutates afterward — a provider's streaming progress summary updates the pane's live subtitle, never the row's title, and the projection enforces a hard single-line length cap. This keeps the track readable like a list of tabs. + +Each row shows **honest cumulative token cost** right of the name — the running Σ(input + output) the daemon accumulates across the subagent's turns (not a last-turn or estimated number), plus `totalCostUsd` when the provider reports one. The accumulator is universal: it works for any provider and any spawn path, including cost-less local models. The collapsed track header sums the total across all rows, so a fan-out's cost is legible at a glance. + +### Row liveness — "alive or hung?" without opening the row + +Beyond name and cost, a row carries three signals that answer whether the subagent is still working, in the order Claude Code's own background-task panel uses them: **elapsed time · tokens · tool uses · current tool**. Each renders only when its source reports it, so a row degrades to exactly what it can honestly say — a missing signal is absent, never a zero or a guess. + +- **Elapsed** is client-derived: a live ticker while the row is running, frozen at `createdAt → updatedAt` once terminal. +- **`toolUseCount`** is cumulative work done and stays on a finished row (`89 tools` is what it _did_). The daemon keeps it monotonic, so a status-only settle can never walk it back. +- **`currentTool`** is the tool it is running or ran last — the signal that turns "spinning" into "spinning _on a Bash_". Unlike the counters it is **not** monotonic (latest wins) and it is **sticky** across an update that omits it, so a scalar-only progress refresh can't blank it. The projection **drops it on any terminal row**: a finished agent isn't running Bash. + +Both wire fields are additive optional leaves on the agent snapshot (`COMPAT(subagentLiveness)`), so old clients ignore them and no capability gate is needed. + +The two sources are deliberately different, and that split is the provider-neutrality rule: + +- **Observed rows** get both from the provider, through the neutral `ObservedSubagentUpdate` — the same plumbing as `cumulativeTokens`. A provider fills them in its adapter (Claude reads `usage.tool_uses` and `last_tool_name` off the SDK's per-task messages; the adapter contract is in [projects/observed-subagents/provider-adapters.md](../projects/observed-subagents/provider-adapters.md)); one that can't leaves them absent and the row simply omits the readout. Nothing daemon-side infers a tool name. +- **Rows with no provider task report** — native `create_agent` children, and a Workflow run's internal agents (whose fan-out carries no per-agent identity on the live stream) — derive both from the subagent's own timeline: count distinct `tool_call` ids, take the latest name. That derivation lives at the daemon's single timeline chokepoint (`recordAndDispatchTimelineItem`), which both the direct stream path and the stream coalescer's flush pass through. The coalescer is also the anti-strobe mechanism: running tool calls arrive batched, so the row re-emits at the coalesce window's rate rather than per event. Native derivation is scoped to agents carrying a parent-agent label — a main chat renders no track row, so counting one would be pure overhead. + +The header aggregate deliberately sums **tokens only**. A tool-use total would silently shrink whenever rows are cleared (only `cumulativeTokens` survives a clear, via the tally below), and a number that quietly drops is worse than no number. + +Completed subagents **tidy themselves without being destroyed**: terminal rows move into a collapsed **"Completed (N)"** group at the bottom of the track, keeping their frozen name and final token total, while the active list shows only in-flight subagents. A manual **"Clear all completed"** gesture archives every terminal row at once (never a running one). Nothing is destroyed until the user clears it or the parent is archived (which cascades), so cost and transcript survive the tidy. + +### Foreground vs backgrounded runs, and what an interrupt actually stops + +An observed row is either **foreground** (its work happens inside the parent's turn, so the provider's interrupt teardown takes it down with the turn) or **backgrounded** (the provider handed back a launch ack and the run keeps reporting on its own). A Workflow run is always backgrounded; a Task/Agent is backgrounded whenever the CLI chose to background it. Both kinds sit side by side in the same track and look identical, so the distinction has to ride on the wire: `ObservedSubagentUpdate.backgrounded`, projected onto the agent snapshot as `backgrounded` (`COMPAT(backgroundedObservedSubagents)`, absent ⇒ foreground). + +The provider is the only layer that can tell them apart, and it does it with the same condition its interrupt teardown uses (`flushPendingToolCalls`): a run still in the tool-use cache is foreground; a Workflow, or a run whose `tool_result` already came back while it kept reporting, is backgrounded. The daemon **latches** the flag (nothing re-attaches a backgrounded run to a turn) and **inherits it down the tree** — a row nested under a backgrounded run survives whatever its ancestor survives. + +This is what makes the interrupting-send confirmation honest. Sending to a busy chat interrupts its turn, and the dialog counts what that interrupt really stops: live **foreground** observed rows only. Backgrounded runs keep going, and attended `create_agent` children are their own chats that the parent's interrupt never touches, so counting either made the dialog claim to stop work it does not reach. `countLiveObservedSubagents` (`packages/app/src/components/interrupt-subagents-warning.ts`) is the one gate; when it returns zero the send goes through with no prompt at all. + +### Auto-clear completed subagents + +A device-local **Settings → General → Agents** toggle (`autoClearCompletedSubagents`, default off) turns the manual clear into an automatic one: while a chat's panel is mounted, tidy-eligible completed rows archive themselves once they've been terminal for a short settle (`SUBAGENT_AUTO_CLEAR_SETTLE_MS`), so a fan-out's finished rows don't accumulate in the Completed group. It's purely visual decluttering — scoped to a chat's subagents track (root chats are untouched), settle-delayed so a row is visibly finished before it vanishes, and it never retries a row whose archive fails (the manual clear stays available). + +Clearing a row (auto **or** manual) would otherwise silently drop its token total from the header's honest fan-out sum, which only counts in-track rows. To prevent that, every cleared row's `cumulativeTokens` is rolled into a per-parent tally (`subagents/cleared-subagent-tokens-store.ts`) that `formatHeaderLabel` adds back in, so **"N tokens"** stays honest after the clear. Like the daemon's `cumulativeTokens` accumulator the tally is in-memory (resets on app reload); the per-chat total (shipped 2026-07-25, charter drained) reads the same tally so cleared descendants keep counting toward the chat total. See [subagent-accounting.md § Chat totals](subagent-accounting.md#chat-totals-one-honest-number-per-chat). + +## The Background Tasks track + +A sibling track (`packages/app/src/background-tasks/track.tsx`), also above the composer, that lists **background shell processes** a provider launched itself — Claude's own `Bash` tool used with `run_in_background: true` — never AI subagents. It renders independently of the subagents track: each is `null` when empty, so either, both, or neither can show at once. + +Unlike an observed subagent, a background shell task is **not** an `Agent` record — no chat, no tab, no pane, no transcript. The daemon (`AgentManager.backgroundShellTasks`, `packages/server/src/server/agent/agent-manager.ts`) projects it as a lightweight `{ id, command, status, ... }` row and pushes the full current list for a parent chat on every change (`background_shell_tasks_changed`, mirroring `terminals_changed`'s reconciliation shape). Row actions: **Stop** while running (resolves to the provider's `stopTask`), **Clear** once terminal (single row or bulk "Clear all"; entries are retired in place with `archivedAt`, never deleted, so a late provider update can't resurrect a cleared row). + +### Three groups: active, Completed, Failed + +Rows partition three ways (`partitionBackgroundTaskRows`), and `status` is the **only** grouping signal: `running` is active, `error` is failed, `idle`/`closed` are completed. Each terminal group collapses into its own header (`Completed (N)`, `Failed (N)`) with its own bulk "Clear all", and the track header summarizes all three ("1 active background task · 2 completed background tasks · 1 failed background task"). A failed row also **renders red** throughout — icon, command, elapsed — so the failure is legible on the row itself rather than inferred from which group it sits in. + +The wire also sets `requiresAttention` on a failure, and grouping deliberately ignores it. Reading that flag instead of `status` is what the first version did: failures were pinned in the active list and excluded from auto-clear, but nothing on the row said why, so they read as ordinary finished tasks that had mysteriously refused to tidy. If you add a new signal here, put it on the row before you let it change where the row lives. + +### Auto-clearing the terminal groups + +Two device-local **Settings → General → Agents** toggles, `autoClearCompletedBackgroundTasks` and `autoClearFailedBackgroundTasks` (both default off), turn the manual clear into an automatic one: while a chat's panel is mounted, rows in an enabled group clear themselves once they've been terminal for a short settle (`BACKGROUND_TASK_AUTO_CLEAR_SETTLE_MS`), so a chatty build's finished shells don't accumulate. One driver instance per group (`useAutoClearCompletedBackgroundTasks({ group })`), each with its own attempted-id set, so neither sweep can ever take the other group's rows. They are **split on purpose**: a finished shell is throwaway (its output is already in the chat), while a failed one is a result you may not have read yet. + +Same shape as the sub-agents auto-clear above — settle-delayed, panel-scoped, never retried on failure (the manual clear stays available) — and a **separate setting and separate driver** from it on purpose too: a cleared sub-agent is an archived chat, which is a heavier thing to discard. There is no token tally to preserve here: background task rows carry no `cumulativeTokens`, so clearing one loses nothing the header was counting. + +All three auto-clear toggles sit in **General**, not Appearance, because they change what the tracks _do_ rather than how they look. They are device-local `AppSettings` (`use-settings/storage.ts`), never synced to the daemon: the daemon-owned equivalents live under the Host group instead. When adding a setting here, sort it by that split first. + +On the Claude adapter (`packages/server/src/server/agent/providers/claude/agent.ts`), background shell tasks ride the exact same `task_started`/`task_progress`/`task_notification` system-message stream as observed subagents — see [observed-subagents.md](../projects/observed-subagents/observed-subagents.md) — discriminated by the originating tool being `Bash` rather than `Task`/`Agent`. Gated behind `server_info.features.backgroundShellTasks`. + +### Edge events vs. the native level signal (Claude) + +The `task_*` edge stream is the provider-neutral spine — it owns row creation and rich terminal status, and it is the shape other providers are mapped onto. On Claude it has one structural weakness: a lost or garbled `task_notification` leaves a row running forever. Foreground `Task` rows are covered by the turn-end sweep (a foreground task cannot outlive its turn), but backgrounded rows — Workflow runs, background shells — are exempt from the sweep and had no other safety net. + +Claude Agent SDK ≥ 0.3.212 emits `system/background_tasks_changed`: a **level signal** carrying the full set of live background tasks with REPLACE semantics, fired on every membership change. `appendBackgroundTasksChangedEvents` reconciles against it: any task id present in the previous payload but absent from the current one is settled (`idle`), covering the lost-edge case. The reconcile never creates rows and never touches foreground rows (only ids seen in a prior payload are eligible; foreground tasks never appear in the level set), and a late-arriving edge for the same transition still lands to refine the status (`idle` → `error`/`closed`). Keep this division when extending: **edges create and describe, the level signal guarantees eventual settle**. Other providers without a level signal keep the edge-only behavior. + +## Why this shape + +The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally: + +- **Closing a tab on a root chat still archives** — preserves the existing UX users are trained on +- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow +- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface +- **Detach button on track rows** — lets a subagent continue independently without killing its work +- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived + +We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-chat users rely on. + +## Limitations + +### Cross-client tab dismissal + +Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup. + +## Storage + +``` +$OTTO_HOME/agents/{cwd-with-dashes}/{agent-id}.json +``` + +`{cwd-with-dashes}` is derived from the chat's filesystem `cwd`. It is not the workspace id; chat storage stays cwd-keyed (under the historical `agents/` directory name) while workspace identity is the opaque workspace id. + +Each agent is a single JSON file. Fields relevant to this doc: + +| Field | Type | Meaning | +| -------------------------------- | ------------- | -------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier | +| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601). Also the sweep's selector — see [Delete](#delete) | +| `labels["otto.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` | +| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | + +See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/docs/client-performance.md b/docs/client-performance.md new file mode 100644 index 000000000..e329700bf --- /dev/null +++ b/docs/client-performance.md @@ -0,0 +1,292 @@ +# Client performance + +How the app measures its own frame rate, retained state and daemon traffic, what those numbers mean, +and the invariants that keep the measurement honest. Read this before changing anything under +`packages/app/src/diagnostics/resource-report/`, and before making a performance claim about the +client — the whole point of this subsystem is that claims come with numbers. + +Sibling page: [terminal-performance.md](terminal-performance.md) covers the terminal pipeline +specifically (its own coalescers, its own benchmark). This page covers the app as a whole. + +## Why the client needs its own instrument + +The daemon has had runtime metrics for a long time (`ws_runtime_metrics` in `daemon.log`, plus +`diagnostics.request`). The client had none. That is the wrong way round for the most common +complaint — "the app gets slower the longer it stays open" — because the Visualizer, which runs in a +**separate Electron `` process**, stays perfectly smooth while the rest of the app +degrades. A separate process being unaffected is the tell: the problem is the app's own JS thread, +its heap, or what it does with what the daemon sends it. None of those were measurable. + +## The subsystem + +`packages/app/src/diagnostics/resource-report/`: + +| Module | Does | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `frame-rate-sampler.ts` | Turns `requestAnimationFrame` timestamps into fps / p95 / jank counts. Pure — driven by timestamps, not a loop | +| `container-census.ts` | Walks client state and emits one metric per container it finds, keyed by path | +| `collect-resource-metrics.ts` | The only impure module: reads the zustand stores, the react-query cache, the DOM, the heap, the daemon clients | +| `resource-metrics.ts` | Shapes those readings into the flat metric record (pins the metric namespace) | +| `resource-trend.ts` | Least-squares fit over the sample ring → **ranked growth**, the leak finder | +| `resource-monitor.ts` | The singleton: rAF loop + census interval + bounded ring buffer | +| `runtime-counters.ts` | Patches the timer globals to count live intervals and pending timeouts | +| `format-resource-report.ts` | Renders it as the `label: value` text the rest of `diagnostics/` produces | + +Surfaces: + +- **Metrics screen**, pinned along the bottom (`components/client-resource-bar.tsx`) — live readout. +- **Settings › Diagnostics › Run app diagnostic** — the `Client resources` sections, copyable. +- **`window.__ottoResourceMonitor`** — the test bridge the soak spec reads. + +Turn it off with **Settings › Diagnostics › Performance monitoring** (`resourceMonitorEnabled`, +default on). Off stops the frame loop and the census interval. + +## Invariants (the easy-to-break ones) + +- **The monitor must not become the leak it hunts.** The sample ring is capped + (`DEFAULT_RESOURCE_SAMPLE_CAPACITY`, ~6h at 10s). The census key space is capped by collapsing + dynamic keys to a wildcard segment: `sessions..agents.size` is one metric across every + host, never one per host id. A census that emitted ids would grow its key space with the data. +- **Arrays are leaves in the census.** Emitting `.length` is the whole signal; descending into + elements would make the census O(timeline items) on every tick, which is itself a perf bug. +- **rAF sampling is web/Electron only.** On native a permanently scheduled frame callback stops the + JS thread idling, which costs battery to measure a desktop symptom. +- **Stalls are excluded from frame statistics.** A hidden window produces one enormous gap; counted + as a frame it would sink every percentile and make an idle app look like the worst offender. Gaps + over `STALL_FRAME_MS` are counted separately as `frames.stalls`. +- **Growth ranking is relative, and gated on monotonicity.** Ranking by raw slope would always + return bytes; ranking without the monotonicity gate would always return whatever oscillates most. + A leak is something that _only ever climbs_. +- **Monotonic is not the same as unbounded.** This is the trap that cost a wrong diagnosis once + already: a metric that climbs to a plateau and stays there has `monotonicity == 1`. Always read + the per-sample series before calling something a leak — `min`/`max`/`first`/`last` in the trend + report, or the raw samples from the bridge. +- **A bounded thing looks unbounded until the run crosses the bound.** The sibling of the trap + above, and it cost the second wrong diagnosis: the workspace deck evicts at three, the soak seeded + three, and "never released" and "never reached the cap" produced the identical series. Before + reading retention off a series, check what caps the thing being measured and **seed above it**. + Where a cap exists, measure its cause directly (the mounted-tree count) and not only its + consequence (`query.observers`). +- **`Client frame drift` is not a verdict below ~40 samples.** The bucket is + `max(1, floor(samples / 10))`, so a 12-cycle soak compares one 10s frame window against one other. + Single windows inside one steady state have ranged from 29 to 85 fps, and the same configuration + has produced both verdicts on consecutive runs — including "degraded" with the deck pinned to a + single workspace. Quote the whole `frames.fps` series, or run long enough for the decile to + contain something. +- **Timer counters patch the globals once, before the React tree mounts.** They are installed from + `app/_layout.tsx` module scope. Installing later means an unknown baseline; unpatching mid-session + would corrupt the counts, which is why the `resourceMonitorEnabled` setting stops the _sampling_ + and leaves the patch in place. +- **`EventTarget.prototype.addEventListener` is deliberately not patched.** Hotter path, and + double-adds of an identical listener are DOM no-ops but would still be counted — wrong in exactly + the case you would want to trust it. + +## Reading `traffic.handlerMs` correctly + +`traffic.*` comes from `DaemonClientRuntimeMetrics` in `packages/client`, summed across connected +hosts. `handlerMs` is **main-thread time spent inside the inbound message handler** — decode, +validate, dispatch to the store. + +It does **not** include the React re-render that the resulting store write triggers. So +`Share of session: 0.25%` bounds the _decode_ cost of daemon chatter, and says nothing about the +_consequence_ cost. A connection can be cheap to decode and still be the reason the UI stutters, if +each message fans out into a large mounted subscriber set. Do not quote the share as "the daemon +connection is not the problem" — quote it as "decoding is not the problem". + +**The client's wire metrics were dormant until 0.6.7.** `DaemonClientRuntimeMetrics` existed but was +only constructed when an embedder passed `runtimeMetricsIntervalMs`, and nothing in `packages/app` +ever did. The counters are now always constructed; the interval still gates only the periodic +`ws_runtime_metrics_client` log line. + +## Measuring + +**Unit level** — the pure modules have tests next to them; run the one you changed: + +```bash +npx vitest run packages/app/src/diagnostics/resource-report/resource-trend.test.ts --bail=1 +``` + +**Soak** — `packages/app/e2e/client-resource-soak.spec.ts`, opt-in like the terminal perf specs: + +```bash +OTTO_RESOURCE_SOAK_E2E=1 OTTO_RESOURCE_SOAK_CYCLES=12 npx playwright test client-resource-soak +``` + +Two tests, and the pairing is the method: + +1. **`repeated chat cycles`** — same workspace, same chat, one more turn each cycle. +2. **`workspace switching alone`** (the control) — identical navigation churn, **no turns**, so the + transcript never grows. Anything still climbing here cannot be explained by "the conversation got + longer". + +The control is what makes the soak diagnostic rather than descriptive. Run both; read the delta. + +`OTTO_RESOURCE_SOAK_WORKSPACES` sets how many workspaces are seeded (default 4). It defaults above +`WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES` on purpose — at or below the cap the deck never evicts, and +the run cannot tell retention from a cap that was never reached. + +**The rule the harness must obey: navigate in-app, never `page.goto`.** A reload rebuilds every +store and empties the query cache — it resets precisely the state being measured. The first version +of this spec used `page.goto` per cycle and reported a perfectly healthy app. + +**Do not edit app source while a soak is in flight — including from another session.** Metro Fast +Refresh re-evaluates the changed module graph, which rebuilds the React tree and re-creates the +resource monitor singleton with an empty sample ring. It is the `page.goto` mistake arriving from +the editor instead of the harness, and it is harder to spot: the daemon client survives, so traffic +counters run on continuously while the census restarts, and the run reports a plausible-looking +short series rather than an error. Two tells — the sample count comes back far below the cycle +count, or a retained-state series dips **below its own one-unit floor** (a deck holding one +workspace cannot drop below one workspace; only an app rebuild can). In a shared checkout, run the +soak from a git worktree. + +**Never time a workspace switch with `expectComposerVisible`.** It resolves `.first()` across every +retained panel, and the deck deliberately keeps the outgoing workspace painted while a cold one +mounts (`useDeferredValue` on a cold selection), so the assertion can be satisfied by the workspace +being navigated _away from_ — it under-reported cold switches by 40%. Wait on the target's own +`workspace-deck-entry-:` instead: an inactive entry is `display: none`, so +its visibility is unambiguous. Note that even this measures **painted**, not **usable** — a cold +mount paints before its timeline refetch lands. + +### The conversation corpus (for the "a lot is open" case) + +The soak above drives **empty** workspaces with **idle** agents. That makes it a good retention +detector and a poor model of the reported symptom: nobody complains about Otto with four empty chats +open. The corpus fills that gap — a synthetic install with several projects, worktree workspaces +under each, a dozen chats per workspace, hundreds of messages per chat. + +```bash +node scripts/seed-perf-corpus.mjs # into the dev daemon; open it by hand +node scripts/seed-perf-corpus.mjs --smoke # 1x1x1, to check the wiring +node scripts/seed-perf-corpus.mjs --clean # drop the previous corpus first +OTTO_CORPUS_SOAK_E2E=1 npx playwright test perf-corpus-soak # the measured version +``` + +The headline test is **`switching between loaded workspaces with both panels open`**, because that +is where the slowdown is actually reported: clicking workspace to workspace in the sidebar, with the +left panel and the explorer both open, so each switch pays for a git status and a diff on top of +mounting the tree. It reports `switch ms` (to painted) and `diff ms` (to a usable Changes list) +separately, then their sum as "switch to usable" -- the panel paints before its diff lands, so +folding them together credits the switch with work the user is still waiting on. + +The seeding logic is one module (`scripts/perf-corpus.mjs`) with two callers, the same arrangement +as the boilerplate-project corpus: a number the soak reports has to describe the state a human can +click through, or it stops being evidence about the app they are complaining about. Scale knobs are +`OTTO_CORPUS_PROJECTS` / `_WORKSPACES` / `_CHATS` / `_TURNS` / `_ITEMS`, shared by both callers. +The default is 6 x 4 x 12 x (10 x 30) = 288 chats and ~86k timeline items, which seeds in about +**four minutes** at `OTTO_CORPUS_CONCURRENCY=24` on a developer machine. + +Four properties of the corpus are load-bearing. Each exists because the obvious simpler version +produces a corpus that measures the wrong thing: + +- **Content is composed, never picked from a fixture list.** The app caches rendered markdown block + heights keyed by the block's own text. A corpus drawn from a handful of fixed strings hits that + cache on nearly every block and reports the app as far faster than it is on real conversations. + `synthetic-conversation.ts` assembles paragraphs from fragments and pins the property with a test + (distinct assistant texts must exceed 90% of assistant messages). +- **Many small turns, not one big one.** The app decides how much history to keep mounted by walking + back from the tail to the nearest user message (`findMountedWindowStart`). The mock provider emits + no user event of its own, so a single 300-item turn contains no user message, the walk runs to + index 0, and the whole transcript mounts. A corpus built that way defeats the windowing it exists + to exercise. Each prompt contributes a real user message, so ten 30-item turns give a 300-item chat + with ten window boundaries in it. +- **Seeds are pinned.** `synthetic-seed: N` in the prompt fixes the generator seed for that turn. + Without it the seed comes from a per-turn UUID, so two seeding runs build statistically similar but + byte-different corpora, and an A/B measurement across them attributes a corpus difference to the + code change under test. +- **Every workspace tree is left dirty.** Eight modified and untracked files per workspace, seeded + deterministically. The Changes view is not a bystander in the reported case: switching workspaces + with the explorer open loads a git status and a diff per switch, and a clean corpus makes that free. + Seeding chats against pristine repos leaves that cost out of every number. +- **Seeding is additive and idempotent.** Existing corpus workspaces and chats are adopted rather + than duplicated, so raising a scale knob and re-running tops the corpus up instead of doubling it. + The result reports `chats` (corpus size) separately from `chatsCreated` (what this run paid for); + collapsing them is how a seeder starts reporting turns it never drove. Only `--clean` resets, and + it clears the daemon records as well as the repos, because orphaned records get adopted next run. +- **The conversations do not survive a daemon restart — only the repos do.** Agent timelines live in + the daemon's in-memory `agent-timeline-store`; `seedAgentTimeline` has no production caller, so + nothing repopulates them on startup. The agent records persist under `$OTTO_HOME/agents/`, so after + a bounce you are left with the full set of chats, all empty. **Seed, then measure without + restarting.** This cost a full round of measurements once: the daemon was restarted to clear an + unrelated problem, every transcript went empty, and the switch numbers taken afterwards described + empty workspaces (~2,600 DOM nodes) rather than loaded ones (~11,700). Adoption verifies content + per chat for exactly this reason, and warns about empty chats rather than counting them. + +Beware one asymmetry when reading results: workspaces above the deck cap are evicted and re-mounted +cold, while chats above the stream-buffer cap of 12 lose their tail buffer and re-fetch. A corpus +dimensioned below either cap measures neither. + +Two things about the surface itself, both of which make a naive reading wrong: + +- **The transcript is virtualized** (`agent-chat-scroll-web-dom-virtualized`), so a chat renders only + its visible window — its node count is set by viewport size, not by the messages behind it. Read + `dom.nodes` as cost per mounted chat; a flat series is virtualization working, not history being + free. It is still the fastest check that a corpus is real: one workspace with loaded chats measured + **~11,700 nodes against ~2,600 when the same chats were empty**, so a suspiciously low node count + means the transcripts are gone, not that rendering got cheap. +- **Chats are tabs, and the strip overflows.** Past a handful, the rest are reachable only through + `workspace-tab-overflow-trigger`, so a harness that walks only the visible strip measures the same + four chats repeatedly while appearing to cover twelve. Wait on the target tab's `aria-selected` + rather than on `agent-chat-scroll`: that container belongs to whichever chat is showing and stays + visible across a switch, so waiting on it returns instantly and reports every switch as free. + +**In production** — Settings › Diagnostics › Run app diagnostic, and copy. The `Client resources`, +`Client frame drift`, `Client growth ranking`, `Daemon traffic` and `Query cache hotspots` sections +are all in the same paste as the daemon's. + +## Known properties of the client (as measured) + +These are the structural facts the instrument has established. They are properties of the code as it +stands, not a status report — what is being done about them is a row in +[`projects/README.md`](../projects/README.md#performance), and the evidence, method and dated +numbers are in [`findings/client-performance/`](../findings/README.md) — the +[FPS-degradation report](../findings/client-performance/2026-07-25-fps-degradation.md) and the +[workspace-tree retention report](../findings/client-performance/2026-07-25-workspace-tree-retention.md) +that corrects its first conclusion. + +- **Mounted workspace trees are evicted, LRU, at a user-set limit.** + `pruneMountedWorkspaceSelections` in `screens/workspace/workspace-deck-retention.ts` keeps the + active workspace plus the most recently active others and fully unmounts the rest. Unmounting + releases the tree's `useQuery` observers — measured, with the released queries showing up as + `query.unobserved`. **The cost of a session is a function of the limit, not of how many workspaces + it has touched.** Marginal cost of one resident tree is +59 to +118 observers and +76 to +169 DOM + nodes, the range depending on what that workspace has open. +- **The limit is `mountedWorkspaceLimit`, device-local, default 5** (Settings › General › + _Workspaces kept loaded_; clamped to 2–12). Device-local because retention is a property of this + machine's memory and this user's habits, not of the project or the host. **The pure retention + module holds no default of its own** — `maxMountedWorkspaces` is a required argument. A fallback + constant there would be a second cap that can silently disagree with the one in Settings, which is + how a user-facing setting stops meaning anything. +- **Set below the working set, the limit is worse than either extreme.** With four workspaces in + rotation and a limit of three, every switch evicts the tree the user is about to return to — + textbook LRU thrashing. This is why the default is 5 rather than a rounder 3 or 4: it covers a + four-workspace rotation with one spare. Raising it costs memory, not frame rate — 3 → 6 resident + trees was within run-to-run noise on every frame metric the soak can read. +- **Navigation asks the daemon for one thing per workspace visited, and nothing per revisit.** + Returning to a workspace used to re-issue `fetch_agent_timeline`, re-ask for setup status, and + re-subscribe terminals, none of which had changed. The three invariants that keep it at the floor: + a chat pane fetches its timeline on focus **only** when history was never applied or the host + reconnected since that agent last synced (`shouldSyncAgentTimelineOnFocus` — live `agent_stream` + plus the reducer's seq/epoch gate covers everything else); a successful setup-status response + carrying no snapshot is **an answer**, cached until a progress push, a workspace removal or a + reconnect; and the terminals push subscription outlives its last observer by + `TERMINAL_SUBSCRIPTION_LINGER_MS`, so a workspace round-trip is a timer cancel rather than an + unsubscribe/subscribe pair. That last one is a **debounce on churn, not a retention policy** — + keep it short enough that it never becomes a second answer to "how long do we keep workspace state + alive", which belongs to the deck's mounted set. +- **`agentStreamTail` / `agentStreamHead` are released when an agent leaves the working set.** The + rule lives in `timeline/agent-stream-retention.ts`: buffers go when the agent is not being + displayed AND either it has left the session (deleted, removed, archived) or it is past the + retention cap, oldest-touched first. Two parts are load-bearing and easy to break: + - **"Not being displayed" is explicit, never inferred.** Every surface that renders the buffers + registers a ref-counted retainer while mounted (`useAgentStreamRetention`). A mounted background + pane is invisible to focus and to lifecycle, so inferring from either blanks it. + - **Releasing the buffers must release the resume state with them.** `agentTimelineCursor` and + `agentAuthoritativeHistoryApplied` are what tell `planInitialAgentTimelineSync` the client is + caught up. Drop the buffers alone and the next open issues an `after` catch-up that returns + nothing onto an empty tail — a blank chat. `releaseAgentStreams` does both; do not split it. + + The Visualizer is unaffected by eviction: its backfill-and-replay re-fetches from the daemon + (`fetchAgentTimeline`), not from these buffers. + +- **The timer counters are the fastest "not this" in the toolkit.** `runtime.liveIntervals` staying + flat retires the timer-leak hypothesis at a glance, and it has stayed flat in every run so far. diff --git a/docs/code-intelligence.md b/docs/code-intelligence.md new file mode 100644 index 000000000..f7b7967af --- /dev/null +++ b/docs/code-intelligence.md @@ -0,0 +1,241 @@ +# Code intelligence (LSP) + +Go-to-definition, hover, find-references, rename and diagnostics resolve through a **real language +server** running in the daemon. The ctags name index (`server/file-explorer/code-index.ts`) remains +as the designed no-server fallback — it still owns the outline and the fuzzy finder — but it is no +longer what answers a definition. + +Editor surfaces and the file tab itself: [text-editor.md](text-editor.md). Protocol naming: +[rpc-namespacing.md](rpc-namespacing.md). + +## Why a server rather than an index + +A name index answers "every symbol anywhere called `foo`". A language server answers "the definition +of _this_ identifier at _this_ position". The difference is not accuracy polish — it is the +difference between a picker of coincidences and a resolved reference. + +The cost is inverted, and being honest about that is part of the feature: + +| | ctags index | language server | +| ----------- | ------------------------- | ----------------------- | +| when | every lookup | once per session | +| where | foreground, on your click | background, after spawn | +| after that | the same cost again | ~10 ms, forever | +| correctness | name match | resolved reference | + +## Layout + +`packages/server/src/server/lsp/`: + +| File | What it owns | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `uri.ts` | `file://` ↔ path over Node's WHATWG conversion, plus `documentKey` — the canonical identity that keeps `c:` and `C:` one file | +| `connection.ts` | One live server: spawn, `vscode-jsonrpc` stdio channel, handshake, per-request timeouts, exit reporting | +| `registry.ts` | The language rows and the workspace-first discovery ladder | +| `pool.ts` | Running servers keyed by (workspace × server): lazy spawn, idle reap, LRU cap, capped-backoff restart, document binding | +| `documents.ts` | The document mirror — didOpen/didChange/didClose, per-document versions, fan-out to every bound server | +| `service.ts` | Query fan-out, merge/dedupe by (path, line, column), the three-valued status | + +RPCs live in `workspace-files-session.ts` beside the ctags `code.symbols` handler, behind the same +workspace guard. The one exception is `lsp.servers.list` with no `cwd`: the guard authorizes reading +a directory, and a host-wide capability listing reads none. Capability flag: +`server_info.features.lsp`. The feature contract applies — **no fallback path**; an old daemon shows +"Update the host to use this." + +## Invariants + +These are the ones that cost something to rediscover. + +- **A query must not depend on another tab being open.** A server that never received `didOpen` for a + document answers **empty, not an error** — so a missing mirror entry looks exactly like "no + definition here". `capableServersFor` syncs the document before querying. This is the single + sharpest gotcha in the subsystem. +- **The wire is 1-based; LSP is 0-based.** Positions on `code.definition` match `CodeSymbolLocation` + and the rest of Otto. `service.ts` is the only place that converts. +- **Identity is `documentKey`, never a raw URI.** Node's stdlib round-trips drive letters, UNC, + `\\wsl$\`, spaces, unicode and `#` correctly, so there is no `vscode-uri` dependency — but a server + may echo a document back in an equivalent-but-different spelling, so every map keys on + `documentKey`. +- **A lazily-spawned server gets `didOpen` with current text, never a replayed `didChange`.** Servers + start on the first query, long after the edits. The per-document record tracks _which connection_ + holds it open, so a crashed-and-restarted server is re-opened rather than sent changes against a + baseline it never saw. +- **Fan-out filters on advertised capability, not on a registry column.** `capableServersFor` reads + `definitionProvider` / `hoverProvider` / `referencesProvider` / `renameProvider` from the server's + own `initialize` reply. A `provides:` column in the registry would be ours to keep accurate + forever. +- **One server answering is success, not a race.** Results merge and dedupe by (path, line, column). + The "every bound server failed" branch must not fire when one capable server answered fine. +- **`ok` with zero locations is authoritative.** The client falls through to ctags only on + `unavailable` (no server for this language on this host) — never on an empty `ok`. +- **Resolution order is workspace-first, always.** The workspace's own `node_modules/.bin` before our + bundled copy before PATH. A server that type-checks the project must be the version the project + installs. +- **Indexing is a real signal, not a timer.** `LspConnection.isIndexing` counts in-flight `$/progress` + begin/end pairs. An empty result while indexing reports `indexing`; otherwise `ok`. +- **`window.workDoneProgress` must be _advertised_ in client capabilities.** The spec forbids a server + sending `$/progress` unless the client asked for it — answering the request is not enough. This + was the root cause of a blocking rename defect, misdiagnosed as a tsserver limitation. +- **`z.object`, not `looseObject`, for LSP reply parsing.** A loose object's index signature collapses + the `Location | LocationLink` union to `unknown`. Prefer `targetSelectionRange` so the caret lands + on the identifier rather than the doc comment above it. +- **`shell: true` is the wrong way to run a `.cmd` shim** — it concatenates argv unescaped, so a + workspace under `C:\My Projects\` splits into garbage. `planLanguageServerSpawn` invokes ComSpec + with `/d /s /c` and explicit quoting. +- **The pool holds no timers**, by design — every decision reads an injected clock, which is what + makes idle/backoff testable without wall time. See the lifecycle warning below for the other half + of that bargain. + +## Lifecycle: the obligation the pool moves outward + +The pool's cost controls (`reapIdle`, `setActiveWorkspace`, `stopWorkspace`, `stopAll`) are written +to be _called from outside_. For several phases they had **zero production call sites** — every unit +test still passed while idle exit, background reap and archive teardown described tests rather than +the daemon. Only the LRU cap actually bounded the process count, and that is a backstop, not a +policy. + +Name the failure mode, because it is not a bug in any one file: "the pool holds no timers" is a +correct testability decision that **moves an obligation out of the subsystem**, and nothing inside +the subsystem can fail when the obligation goes unpaid. + +| Obligation | Who pays it | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| The idle tick | `websocket-server.ts` — `startLspIdleReapInterval`, every 30 s, `unref`'d, cleared in `close()` | +| Which workspace is "active" | `LspService.setActiveWorkspace`, from its own request path (`syncDocument` + `capableServersFor`) | +| Teardown on workspace archive | `archiveByScope` via the `stopLanguageServers` dependency, gated on the same last-reference rule the directory removal uses | +| Teardown on daemon shutdown | `websocket-server.close()` → `lspService.stopAll()` | + +- **The active workspace is derived, not pushed.** There is no focus signal on the wire and adding one + would give the idle policy a second source of truth — the stale one leaves servers running. Every + code-intelligence request already _is_ a focus signal: hover, definition and buffer sync only + happen for the file someone is looking at. +- **Archive teardown is keyed by directory, not by workspace record**, because that is how a server is + keyed. Two workspace records over one `cwd` mean archiving the first must not stop the servers the + second still uses — the same `isDirectoryUnreferenced` predicate that decides whether the worktree + directory may be removed. +- **No teardown may fail an archive.** The archive has already happened; a server that refuses to die + is logged and left to the reaper. + +Regression guard: `websocket-server.lsp-lifecycle.test.ts` — that the daemon ticks and that shutdown +stops everything. Not what the pool does with the tick; `pool.test.ts` owns that. + +## The indexing-cost policy + +What people object to is _invisible, always-on, unbounded_ indexing, so the policy is part of the +feature: + +- **Lazy spawn.** No server starts until a code-intelligence action needs that language in that + workspace. Never open Rust, never pay for rust-analyzer. +- **Per-language opt-in**, with the cost stated next to each toggle. +- **Idle shutdown.** An unused server exits after N minutes; memory comes back. +- **Visible.** "Indexing TypeScript (first run)…" with elapsed time — never a spinner that reads as + hung. +- **A zero-index tier.** The ctags index stays and is the honest answer for anyone who wants no + servers at all. + +Per-server reality, so settings copy can be honest: + +| Server | Index cost | +| ------------------------------------- | ------------------------------------------------------------------------------- | +| JSON / CSS / HTML / Bash | **none** — per-document, no project model | +| pyright, gopls | seconds | +| typescript-language-server (tsserver) | seconds to ~30 s cold on a repo this size; 1–4 GB resident | +| clangd | background **on-disk** index; needs `compile_commands.json` to be useful at all | +| rust-analyzer | the expensive one — minutes cold, re-runs on dependency changes | + +## Languages + +A language is a **registry row** — id, extensions, command, discovery ladder, init options — not +code. The count is cheap; reachability on the user's machine is what varies. + +TypeScript/JavaScript, Python and C# are not "tier one", they are the **acceptance criteria**. + +| Language | Server | Launch | Acquisition | +| ------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| TS/JS/TSX/JSX | `typescript-language-server --stdio` | resolve the workspace's own TypeScript so the answer matches its build | workspace `node_modules/.bin` → our dep → PATH | +| Python | `pyright-langserver --stdio` | plain | our dep → PATH | +| C# | `csharp-ls` (stdio is its default — **no** `--stdio` flag) | plain stdio | `dotnet tool install -g csharp-ls`, then PATH — **user-consented, never automatic** | + +Empirically settled, and worth not re-deriving: + +- **`typescript-language-server` 5.3 sends no `serverInfo`** — only `capabilities`. It is optional in + LSP; do not assert on it. `csharp-ls` **does** send it. +- **There is no `roslyn-language-server` to install.** Not on nuget.org, not on npm, and + `dotnet tool install -g roslyn-language-server` **exits 0 while failing** — a naive install step + would report success. Editors that consume Roslyn directly unpack the nupkg themselves; that is a + bootstrap we are not writing. `csharp-ls` is the row that works. +- **C# needs no per-language hook.** It initializes as an ordinary stdio server against a loose + folder, a classic `.sln`, and .NET 10's `.slnx`, finding the project itself. +- **.NET 10's `dotnet new sln` emits `.slnx`, not `.sln`.** Anything detecting "is this a solution + repo" must match both. + +### Linters are language servers too + +`oxlint --lsp` is a real LSP server and ships as a row with discovery **`workspaceBin` only**. That +is not a packaging accident: a linter's rules are the project's own opinion, so falling back to our +bundled copy would fill a repo's gutter with rules its authors never chose. A repo without oxlint +honestly gets no lint diagnostics. + +**This row is what forced capability-based fan-out, and it is the better design** — oxlint binds +`.ts` beside the TypeScript server but answers only diagnostics. It is also the first production user +of multi-server binding. + +**ESLint is not shipped, and shipping it blind would be worse than the gap.** `vscode-eslint`'s server +requests real settings through `workspace/configuration`, and this client answers `{}` for every +item — enough for pyright, almost certainly not for ESLint, which would then validate nothing and +read as a broken row rather than an absent one. It needs the same empirical probe `csharp-ls` got. +`tslint` is not a candidate: deprecated in 2019, folded into `typescript-eslint`. + +**Never download a binary silently.** npm servers ship as optional dependencies; everything else is +discovered, and when missing the answer is an offer (with the exact command shown) or a plain "no +language server for Go" — never an error, never a background download. + +The editor's ~20 Lezer grammars (`packages/highlight/src/parsers.ts`) are independent: a language can +have a server without a grammar and vice versa. + +## Settings — Daemon → Code + +`MutableDaemonConfig.lsp` (master switch, sparse per-language map, limits) plus `lsp.servers.list` / +`lsp.server.stop` for live state, rendered by `screens/settings/code-intelligence-section.tsx`. + +- **Config and live state are separate RPCs on purpose.** Which languages are enabled is + configuration; which servers this host can supply and which are running is not. +- **`languages` is sparse, and an absent key means "use the row's default"** — so a row added later + ships with its intended default instead of reading as disabled by an older config file. +- **`defaultEnabled` lives on the registry row, not in the config.** TypeScript, Python and C# ship + **on** — a language that must be switched on before it works has not shipped. Anything with a heavy + index (rust-analyzer, clangd) ships off. +- **The master switch defaults on, and that is safe** because spawning is lazy. What the switch + guarantees is that off means off _now_ — `applySettings` stops what is running rather than waiting + for an idle timeout, or the switch is decoration. +- **The listing is host-wide and unconditional.** Settings is a host screen and has no workspace in + hand, so `lsp.servers.list` is sent with **no `cwd`**: every row this daemon knows, the toolchain + behind it (resolved path plus the rung it came from), and whether this machine can supply it. A + screen that asked the user to open a workspace before it would name a machine's capabilities was + gating a host fact behind a client one. +- **Per-project availability lives on the row, not on the screen.** The `workspaceBin` rung is the one + thing a host-wide answer cannot know, so `resolveServerCommand(row, null)` skips it and rows whose + only rung is `workspaceBin` (oxlint, Angular) report "comes from the project that uses it" rather + than "not installed". Passing a `cwd` still probes that project's `node_modules/.bin`; nothing on + this screen does. +- COMPAT(lspHostServers): the no-`cwd` form arrived in v0.7.3 behind `features.lspHostServers`. Older + daemons reject it, and probing another machine's PATH is not something a client can do, so the + screen says to update the host. No fallback path. +- The running-servers table is a real table (server / root path / uptime / Stop), per the repo's + "data needs a table, not a card" rule. The root path is a fact about a running process, not a + workspace the screen knows about. + +## Rename is a reversible job + +Rename does not apply edits from the client. `code.rename.plan` returns a **planId** and a preview of +the affected files; `code.rename.apply` carries **the planId, not the edits**. The daemon owns the +plan, so the client cannot smuggle a mutated edit set past the preview the user approved. It surfaces +as its own job tab, like Refine. + +## Deferred + +- **Angular / multi-server-per-document framework rows.** Deferred indefinitely — the multi-server + binding it existed to prove now has a real production user in the `oxlint` row. +- **The ctags fallback's incremental re-index.** It should build once and re-index only the written + file (the write path already knows the path) instead of invalidating the workspace. That alone + removes the latency complaint for the no-server case. diff --git a/docs/coding-standards.md b/docs/coding-standards.md new file mode 100644 index 000000000..3dc337105 --- /dev/null +++ b/docs/coding-standards.md @@ -0,0 +1,104 @@ +# Coding Standards + +The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision. + +For testing rules, see [testing.md](testing.md). + +## Core principles + +- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit. +- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction. +- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff. +- **Functional and declarative** over object-oriented. +- **`function` declarations** over arrow function assignments. +- **`interface`** over `type` when both work. +- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source. + +## Comments and noise + +- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_. +- No tutorial comments explaining language features (`// Use destructuring to...`). +- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art. +- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate. +- No commented-out code. Git remembers. +- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it. + +## Confidence: commit to a shape + +- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says. +- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality. +- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter). +- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch. +- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries. + +## Types + +- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less. +- If a Zod schema exists, the TypeScript type is `z.infer`. Never hand-write a parallel type. +- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields. +- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args. +- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time. +- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site. +- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags. + +## Errors + +- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string. +- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`. +- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI. +- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y. +- **Never surface an error to the user with `Alert.alert`.** `react-native-web`'s Alert is `static alert() {}` — a literal no-op — so an alert-only failure path is invisible on web and Electron desktop, and the UI just looks stuck (the classic symptom: a modal whose Save does nothing and then claims unsaved changes). Use `alertDialog` / `confirmDialog` from `@/utils/confirm-dialog`, which render through the globally-mounted `ConfirmDialogHost` on every platform. +- Everything between the click and the write belongs inside the `try` that reports failure — draft conversion, id generation, validation. A throw one line above the `try` is a silent dead end. + +## Density + +- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`). +- Boolean expressions with 2+ clauses or mixed concerns → name the conditions. +- Object literals assemble pre-computed values; don't pack branching and lookups into property positions. +- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates. +- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract. + +## Structure and modules + +- A directory is a module, not a namespace. One intentional public surface; internal files stay internal. +- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path. +- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain. +- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere). +- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it. +- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it. +- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case. +- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace. +- Don't drop new files at the nearest root just because placement is unclear — say so and ask. + +## Refactoring is a bolt-on test + +- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead. +- Refactors preserve behavior by default. No removing features to simplify code without explicit approval. +- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md). +- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed. + +## React + +- `useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`. +- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer. +- `useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union. +- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse. +- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer. +- Never define components inside other components. Module-scope only. +- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects. +- Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update. +- Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed. +- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work. +- Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second. +- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary. +- Use stable ids for `key`, never array index for reorderable/filterable lists. +- Context for stable values (theme, auth). Store with selectors for state that changes. + +## Naming + +- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`. +- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`. +- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape. +- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong. +- Booleans read as yes/no questions: `isX`, `hasX`, `canX`. Avoid negative booleans (`isNotConnected`). +- `data`, `result`, `info`, `manager`, `temp` are smells — say what the thing _is_. diff --git a/docs/context-management.md b/docs/context-management.md new file mode 100644 index 000000000..f998d5b9e --- /dev/null +++ b/docs/context-management.md @@ -0,0 +1,457 @@ +# Context Management + +**Everything a provider sends before you type a word** — resolved into a graph, measured as a share +of the model's context window, and made editable. It answers the question the token ledger cannot: +_what fixed weight am I carrying every single turn, and how do I cut it?_ + +The division of labour is deliberate. +[subagent-accounting.md § Chat totals](subagent-accounting.md#chat-totals-one-honest-number-per-chat) +owns _"what did this chat cost"_ — spend, which only grows. Context Management owns _occupancy_ — +the fixed tax paid on every request before the conversation starts. They share no units and must +never appear in one readout; see [glossary.md](glossary.md). + +Gated by `server_info.features.contextManagement` (`COMPAT(contextManagement)`, v0.6.5) and the +`contextManagement` entry in `features/feature-catalog.ts`. + +## The two kinds of edge — the fact everything else rests on + +| Edge | Syntax | In the request? | +| ---------------------------------------- | -------------------------------------------- | --------------------------------------- | +| **Hard / import** — UI: _"Always load"_ | `@docs/foo.md` | **Yes**, inlined recursively at load | +| **Soft / reference** — UI: _"Link only"_ | `[foo](docs/foo.md)`, or prose naming a path | **No.** Only the link text costs tokens | + +Get this wrong and every number is a lie. This repo's own root `CLAUDE.md` links ~45 `docs/*.md` +and ~30 `projects/*.md` files and **loads none of them**. A scanner that treated a markdown link as +a context edge would report several hundred thousand tokens against a true cost of ~6K. + +Soft edges are still worth drawing, because they are **read magnets** — a documented invitation for +the agent to pull the file in mid-turn (this repo literally instructs agents to read +`docs/preview.md`). That is not fixed cost; it is _probable_ cost, and it gets its own total and a +dashed edge rather than being folded into the headline. + +### Three cost classes + +| Class | When it loads | Examples | +| --------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| **Fixed** | every request from turn one | root context files + their imports, memory index, tool schemas, skills roster, system prompt, personality/team prompt | +| **Conditional** | when the agent touches that area | subdirectory `CLAUDE.md`, skill bodies, recalled memory entries | +| **Referenced** | only if the model chooses to read it | soft-linked docs | + +Subdirectory context is **not** start-of-session cost — verified live: reading a file under +`packages/server/` caused the harness to inject `packages/server/CLAUDE.md` mid-session. + +### What is actually controllable + +The tool claims control only where control exists, which is roughly 80% of the weight. + +| | Controllable | Lever | +| ------------------- | ------------ | ---------------------------------------------------------------------------------------------------- | +| Fixed → conditional | ✅ | flip the edge to _Link only_ (below) | +| Fixed → conditional | ✅ | demote a rule into a subdirectory context file (not built — see the ledger) | +| Skills roster | ✅ | disable a skill, **or shorten its description** — the description is the fixed cost, the body is not | +| MCP tool schemas | ✅ | disable servers per project | +| Memory index | ✅ | compact the index; entries are recalled, not fixed | +| Referenced → read | ❌ | you cannot control whether the model reads a link. **The UI says so.** | + +## The inventory — the root is not "CLAUDE.md" + +The tree root is **"Sent before you type"**. Context files are one branch of six +(`ContextCategory`, `agent/context-management/types.ts`): + +| Category | Source | Otto's visibility | +| --------------- | ------------------------------------------------------ | ---------------------------------------- | +| `context_files` | the CLAUDE.md / AGENTS.md graph plus imports | convention scan | +| `memory_index` | `MEMORY.md` (entries are recalled, **not** fixed) | convention scan | +| `skills_roster` | name + description per skill **and per subagent** | filesystem scan | +| `mcp_tools` | tool JSON schemas per connected server | daemon-known | +| `otto_injected` | personality prompt, team snapshot, injected Otto tools | daemon-owned, exact | +| `system_prompt` | provider preset | opaque for CLIs; exact for openai-compat | + +Measuring only markdown was the failure mode this inventory exists to prevent. The fork's own +token-cost audit measured ~9.7–14.9K tok/request for this repo against ~6K `CLAUDE.md` + ~5K +`MEMORY.md` — context files were roughly half, on a workspace with no heavy MCP load. A tool that +trims 3K and barely moves the bill loses the user's trust on its first use. + +### An unmeasurable category is a row, not a gap + +Zero tokens means two different things — _there is none_ and _Otto cannot see it here_ — and the +first version of this tab rendered both as nothing at all: `categoryTotals` filtered out anything +weighing zero, so the categories no producer ever populated (`system_prompt`, `mcp_tools`) were +indistinguishable from categories that genuinely did not apply. Three of the six advertised +categories were structurally incapable of reporting a number, and the UI said so by staying silent. + +Each total therefore carries a `visibility` (`ContextCategoryVisibility`: the three confidence +levels plus `not_visible`), and a `not_visible` category is emitted **even at zero tokens** — that +row is the disclosure. It renders the reason in the slot where every other row puts a figure, +because "0" is a measurement Otto never made and blank reads as "nothing here", which is the one +wrong conclusion available. + +The rows still cannot inflate the headline: `not_visible` carries zero by construction, so +`fixedTotal` remains a sum of what was actually measured. `visibility` is optional on the wire +(`COMPAT(contextCategoryVisibility)`) — an older client ignores it and still gets correct totals. + +**Roster weight includes plugins.** `resolveSkillRoots` originally covered only the user's and the +project's own skill directories, which on a host with plugins enabled is a small fraction of the +real roster — measured on this machine, 7 entries against a true 13. `plugin-roots.ts` reads +`enabledPlugins` from the provider's settings (local file overriding global), resolves each to its +install directory, and discovers the version segment rather than assuming one: the cache uses the +literal string `unknown` for unversioned plugins. Subagent definitions ride the same category — +they are advertised to the model as a name plus a description exactly as skills are. Both stay on +`skills_roster` deliberately: `ContextCategory` is a `z.enum` travelling daemon→client, so a new +member would make a new daemon's report unparseable by an older client. + +The roster remains a **floor**, not an exact figure: skills bundled with the provider's own +application do not live under its config directory, so a convention scan cannot see them. Say that +on screen; do not model around it. + +### Per-provider resolution, confidence-tagged + +`provider-conventions.ts` is a registry (`getProviderConvention`, `isContextScanSupported`), with +entries for **Claude**, **Codex** and **OpenCode**; anything else reports no scan rather than a +guess. Each report carries `confidence: "exact" | "convention" | "unverified"`, and a convention +that has not been differentially measured must never be presented as fact. + +**openai-compat is the reference provider, not the excluded one.** It is the only provider where +Otto builds the payload itself and therefore knows it exactly — which makes it the ground truth +every convention-based estimate is validated against. (The predecessor charter marked it "not +applicable"; that was backwards.) + +## Severity is a share of the window, never absolute tokens + +6K tokens is a rounding error at 1M and a catastrophe at 32K. Otto ships LM Studio as a +first-class citizen, so an absolute threshold is indefensible. + +| Level | Share of window (per category **and** aggregate) | +| ----------------------- | ------------------------------------------------ | +| `ok` (silent) | < 10% | +| `notice` (panel only) | 10–24% | +| `warn` (amber flyout) | 25–49% | +| `critical` (red flyout) | ≥ 50%, or fixed context exceeds the window | + +`DEFAULT_CONTEXT_THRESHOLDS` in `agent/context-management/evaluator.ts`, overridable through +`MutableDaemonConfig` on the rate-limit/speech hot-reload pattern. + +Three rules ride with the percentage: + +- **Report working room, not just a share.** Fixed context at 44% means the conversation _and_ the + response share what is left, so the headline reads _"leaves ~110K of working room"_ next to the + percentage. +- **Say the caching caveat out loud.** Fixed context is exactly what providers cache — expensive + once, cheap thereafter. "14K every request" is token-true and cost-misleading, and without the + caveat the money framing is simply wrong. +- **Never default the window to the largest option.** Presets are 32K / 128K / 200K / 262K / 1M + plus custom; the picker is a **what-if** (_"evaluate as if running: provider × window"_) and + defaults to the active agent's model window when known, else **200K**. Defaulting to 1M would + report "you're fine" to everyone and make the tool useless. _Today nothing populates the real + model window_ — `WorkspaceContextRuntime.windowTokens` accepts one, but no provider-neutral + model-window lookup exists, so 200K is what you get. + +## The tab: three panes, no sub-tabs + +``` +┌──────────────────┬────────────────────────────────┐ +│ 1. Health summary│ │ +│ + window / │ 3. Viewer / Editor │ +│ provider │ (the existing file tab) │ +│ picker │ + context operations │ +├──────────────────┤ │ +│ 2. Context graph │ │ +│ tree │ │ +└──────────────────┴────────────────────────────────┘ +``` + +`packages/app/src/context-management/` — `panel.tsx`, `summary.tsx`, `graph-tree.tsx`, +`sidebar-tabs.tsx`, `findings-list.tsx`, `store.ts`, `use-context-report.ts`. On a compact form +factor the three panes collapse to a drill-down stack (summary → tree → editor) with back +navigation. + +**Pane 3 shows a file or a prompt section, never both.** They are one selection, and whichever was +picked last owns the pane — `use-context-selection.ts` holds that rule because three call sites move +it (the tree, the fix list's reveal, the report re-seed) and each getting the precedence right +independently is how two rows end up highlighted at once. + +**The sidebar tab strip spans the width left beside the compaction action** (`stretch`). Three +segments at most — Context, Memory, Issues — so an equal split still fits "Issues (40)". + +**Both scope pickers are dropdowns, on one row.** _"Evaluate with"_ (window) and _"Viewing for"_ +(personality) share `scope-select.tsx`, which wraps `SelectField` and puts the label +inside the trigger so each control reads as a sentence its value finishes. They started as two +wrapping chip rows and cost four rows of the panel's first screen before a single number appeared — +and the personality row grew without bound, one chip per name the host collects. A dropdown costs +one row whatever the roster does, and it has somewhere to put the search field that a long roster +needs (`SEARCHABLE_ROSTER_SIZE`, currently 8). Anything else that has to be chosen up here goes in +the same row, in the same control — this is the panel's densest screen and it does not get a third +shape. + +### Never open on a blank tab + +A scan is a filesystem walk of every context file the provider loads plus every markdown file they +link to — measured on this repo, ~220 files / ~1.7 MB and **150–260 ms** in the daemon, with +outliers past 1.5 s under load. That is real work, not a bug to optimise away. The failure it +caused was that the tab spent that time _looking broken_: a muted summary line, "Nothing to show +yet" in the tree, "Pick a file" in the pane — none of which distinguishes **scanning** from +**empty**. + +Three rules, enforced in `use-context-report.ts`: + +1. **Answers outlive the tab.** Results cache in the store keyed by + `serverId:workspaceId:provider:windowTokens`, so re-opening paints the last answer immediately + and revalidates behind it. `isLoading` (nothing to show) is exposed separately from + `isRefreshing` (numbers on screen may still move); only the first blanks anything. +2. **The pushed baseline seeds the first open.** The composer already primes a report per + workspace, so when it was evaluated against the same window the tab starts from it. +3. **Identical scans coalesce.** A module-level in-flight map collapses two panes on one workspace + — and the throwaway request the tab fires before persisted settings hydrate — into a single + walk. Only `refresh()` (which follows a write) bypasses it. + +A stored `null` is a real answer and must not be papered over with the previous report, and a +failed scan says so. An unexplained empty panel is the one outcome this section exists to prevent. + +### The graph is a DAG, not a tree + +Four dedup rules, all load-bearing: + +1. Every file appears **exactly once**. First visit wins, in load order + (enterprise → user → project → local → subdirectory). +2. Additional parents render as a dimmed _"also imported by X"_ chip on the same node — never a + second node. +3. **Cycle detection** (`import_cycle`) and a depth cap matching the provider's (`depth_capped`). +4. **Token totals are deduplicated too.** A file imported twice is sent once; counting it twice + makes the headline a lie. + +Every node carries a **scope badge** (`Global` vs `This project`) because a user editing +`~/.claude/CLAUDE.md` is changing every project on the machine and must know that before, not +after. Cost class is visually distinct; solid edge = always loaded, dashed = link only. + +**Do not reuse the sidebar explorer's data source.** Reuse its row primitives and visual language +only. The explorer is filesystem-shaped; this is load-graph-shaped, spans multiple roots outside +the workspace, and carries typed edges — forcing it through `file_explorer_request` fights the +model. Note "roots" is plural: up to five load points can exist at once, so there is no single +root to open to. The pane opens to the highest-impact **project-scoped** file. + +**No context file is not an error.** It is the default for every new user, so the empty state is +"Set up your project context" — generate a starter file from the repo through the draft → review → +save path, never an auto-write. Zero → written → trimmed is what makes this a management surface +rather than a nag. + +## Prompt sections — reading, never editing + +The graph answers _what is loaded_ and _what it costs_. It cannot answer the question users ask +first — **"so what is the model actually reading?"** — because a tree of filenames and token counts +never shows the thing itself. `prompt-preview.ts` assembles the real content. + +**It is a row in the tree, not a tab.** Most rows resolve to a file, and clicking one opens it in the +editor; the rows that do not — `otto_injected`, and on openai-compat `system_prompt` and `mcp_tools` +— are prompt text Otto composes at request time, and clicking one opens that text read-only in the +same pane (`PromptSectionView`). One gesture, one pane, and the answer sits under the row that +raised the question. A whole-prompt tab was tried first and removed: it stacked every section into +one 11K wall in which the thing you clicked for was unfindable. + +Five rules, all load-bearing: + +- **One section per request.** `context.prompt.preview.get` takes an optional `category`, and the app + always sends it. Reading Otto's injected stack must not re-read every context file on disk to + build text nobody asked to see — and must not report their tokens either. +- **A prompt row shows the whole stack for that category, and nothing else.** `otto_injected` is the + system-prompt override, the daemon append (where team and personality role text land) and the + personality's memory brief, in injection order. The brief is in the row's token total, so leaving + it out of the text would make the pane and the row above it disagree about one number. +- **Derived, never authoritative.** Sections are re-read from the files the scan resolved, and there + is **no matching write RPC**. Editing stays per file in the existing pane, against the real file — + so a stale preview can only be stale, never wrong in a way that lands on disk. +- **Built on `getReport`, not beside it.** The preview shows exactly what the graph counted, under + the same provider/window/personality what-ifs. Two independent resolutions of "what is loaded" + would eventually disagree, on screen, about the same request. +- **Fixed weight only, and the roster shows frontmatter only.** Conditional and referenced files are + not in the request; a skill's body is not either. Rendering either would contradict the token + figure sitting next to it — which is precisely the misconception this view exists to end. + `extractFrontmatter` is shared with the scanner so the two can never drift. + +**A category Otto cannot see leaves the tree.** `not_visible` with no files under it means the +provider composes that part inside its own process: no number, no text, nothing to expand. On every +CLI-backed provider that is `system_prompt` and `mcp_tools`, and a permanent "not available here" +row is a dead end rather than a disclosure. Where Otto owns the payload (openai-compat) the same two +categories are measurable and readable, and the rows stay. This is the one place the `not_visible` +rule hides a row instead of explaining it — everywhere else the disclosure has a number or a parent +to attach to. + +## Operations + +### Load mode: Always load ↔ Link only + +The single most valuable operation, and the one the whole `sourceRange` design exists for. +`ContextEdge` carries the exact byte range of the reference in its parent, so flipping a hard edge +to a soft one (or back) is a one-line deterministic edit rather than a re-parse. It operates on the +**edge**, not the file — a multi-parent node converts only its own edge — and it runs daemon-side +(`context.edge.convert.request`) because the target may live outside the workspace. + +`load-mode-control.tsx` is the control, and two things about it are deliberate: + +- **It never says "import".** Users should not have to learn a provider's syntax to control their + own bill, so the segments read **Always load** / **Link only**, and the token delta is stated up + front so the choice is informed rather than a leap. On a provider with no import mechanism the + control is disabled _with the reason_, not hidden. +- **It rides in the file pane's toolbar on desktop** (`toolbar` layout) — a second full-width bar + cost a row of height to say two words — and falls back to a standalone `strip` on phones, where + the toolbar has no width to spare. + +### Deterministic findings come first + +Free, high-confidence, and far more reassuring than asking anyone to trust a model. The shipped +set spans the scanner (`dead_import`, `dead_reference`, `import_cycle`, `depth_capped`) and the +content pass (`content-findings.ts`): `duplicate_across_scope` — rules duplicated between global +and project scope, which is **pure double-billing** and something users almost never know they are +doing — `duplicate_within_file`, and `oversized_memory_entry` (index lines that outgrew the +one-line convention). + +**Every finding says where it is.** A finding is stamped with its owning `nodeId` and 1-based +`line`/`lineEnd` as it is created (`finding-location.ts`); the flat report list has no other way to +know, and a row that cannot name its file is a complaint rather than a task. The Issues row is +therefore a jump — it forces the file out of rendered-markdown preview into the editor (a +finding is a request to _edit_, so it overrides the per-file mode memory exactly as the explorer's +"Edit" does), **selects** the offending span via `EditorController.selectLines`, scrolls it to +centre and focuses so one keystroke replaces it, reveals and selects the file's row in the tree, +switches back to the Context tab, and repeats the finding in a banner over the editor so it stays +readable while being fixed. The file comes from `nodeId`, never `relatedNodeIds` — the latter is +the _other_ half of a cross-scope duplicate, which is exactly the confusion the jump exists to end. + +The row's leading mark is **scope, not severity**. Everything in the list is already worth fixing, +so the open question is how far the fix reaches. It shares `scope-icon.tsx` with the tree so a file +and a finding about that file are never labelled differently, with one difference: the tree +suppresses `project` as its default-and-therefore-noise case, and the fix list always states it. + +Three traps paid for once, worth not re-learning: + +- **Taking focus needs persistence, not one call.** `view.focus()` lands when the editor is already + on screen and loses when it has only just mounted — the click's original target is still being + torn down and the browser hands focus back to `document.body` _after_ we asked. `editor-core.ts` + re-asserts focus for ~4 frames, stopping the moment `view.hasFocus` is true so it can never fight + a user who clicks elsewhere. Relatedly, `handleReady` reveals on _every_ editor mount, not just + the first: the editor remounts whenever the file changes, so a once-only guard opened the second + file you jumped to at line 1 with nothing selected. +- **The tree's `scrollToIndex` fires while the FlatList is still mounting** (the reveal is what + swaps the fix list out for the tree), so it must retry through `onScrollToIndexFailed` or it + silently never scrolls. +- **Finding ranges are UTF-16 string indices, not byte offsets**, despite the field's name — which + is why the client is handed line numbers rather than raw offsets to map itself. + +### Fix all: mechanical deletes only, no model in the loop + +Four of the seven finding kinds have a safe answer a plain delete can give: `dead_import` and +`dead_reference` are just removed, `duplicate_within_file` and `duplicate_across_scope` are a copy +of content that survives elsewhere. `finding-location.ts` stamps that verdict once, centrally, as +`fixable` — every finding also carries a `snippet` (the exact text at `range` when scanned), which +is the staleness guard `context.findings.fix` checks before deleting anything. + +`import_cycle` (which edge to cut), `oversized_memory_entry` (where to split it), and +`depth_capped` (nothing to delete at all) stay off the fixable list on purpose — those need +judgment a mechanical pass cannot supply, and shipping a wrong guess there would cost more trust +than leaving the row for a human. + +The Issues tab's "Fix all" button (`findings-list.tsx`) sends every fixable finding's +`{filePath, range, snippet}` in one `context.findings.fix.request`. `finding-fix.ts` groups them by +file and deletes back-to-front by range within each file — an earlier deletion never shifts the +offset a later one still needs — verifying each snippet against the file's _current_ contents +first, so an edit made since the scan skips that one finding rather than corrupting the file. A +non-empty `fixedCount` invalidates the report and pushes a fresh one, the same reconciliation +`context.edge.convert` already does. + +### Compaction is Refine, not a feature of its own + +The requirement was never "a compact button"; it was **a side-by-side diff with per-hunk +accept/reject before anything lands**. For a file whose entire purpose is behavioural rules, +"review the prose that came back" is not enough — the user has to see what got dropped. So +compaction ships as two presets over [Refine](refine.md), and `context-management/refine-action.tsx` +is the call site. + +It opens a Refine job with the selected file **rewritable**, the rest of the context graph as +**read-only references** budgeted smallest-first (`refine-reference-budget.ts`, 60 KB / 12 files, +so the seed cannot blow the daemon's per-request ceiling and fail a round the user has no way to +fix), and a preset chosen per file by `presetForContextFile`. Two presets, not one, because an +index and an instruction file fail in opposite directions — an index wants detail moved _out_, an +instruction file wants every rule kept — and one button meaning both would either bloat the index +or quietly drop a rule. + +**Compact is a graph action, not a file action**, and that is why it sits left of the +Context/Issues segments (`ContextSidebarTabs`' `leading` slot) rather than in the file toolbar: +Refine in the file toolbar opens the file on screen and nothing else, while Compact opens a job +carrying the whole graph. It renders **disabled** rather than absent when nothing is selected — +that row is chrome the eye returns to, and a control that vanishes reflows the segments beside it. + +**Auto-compaction without review is permanently out of scope.** + +## Surfacing: the composer flyout is a doorbell + +`ContextHealthTrack` (`composer/context-health-track.tsx`) mounts in the composer fly-out stack +immediately above `RateLimitWarningTrack`, so it is topmost in the fan. The warning lives there +rather than in the chat content area to keep two rules clean: suggested tasks stays a content +overlay, and _all warnings come from the composer_. + +``` +⚠ Project context is 14.2K tokens — 44% of this model's window, every request. [Manage] [×] +``` + +- **Amber** = costing you money, nothing is broken (nearly every case). **Red** = actually blocked — + fixed context exceeds the window, or a required import is missing. It inherits the rate-limit + track's `approaching`/`rejected` semantics exactly and invents no new ones. +- **One action: `Manage`.** The chip does not open files, offer compaction, or explain the graph. +- **Dismissal is mute-with-key**, bound to severity + size bucket and self-expiring, so an + escalation mints a new key that breaks through the mute immediately. It is **per-workspace**, not + per-agent — context health is a property of (workspace, provider), and without that the same + warning nags on every tab in a project. +- **Dismissal is device-local**, not server-side. It mirrors the proven `rateLimitDismissKey` / + `mutedUntil` shape in the client store. Server-side sync needs a new persisted daemon store and + was deferred deliberately. +- **Stack budget:** four flyouts can fan above the composer (context, rate limit, subagents, + background tasks). Cap at two warning-class flyouts visible; lower-priority ones collapse to a + count. + +## Protocol + +Per [rpc-namespacing.md](rpc-namespacing.md); all fields additive and optional per the +back-compat rule. + +- `context.report.get.request` / `.response` — carries optional `{ provider, windowTokens }` for + the what-if pickers. +- `context.edge.convert.request` / `.response` — `{ edgeId, target: "import" | "reference" }`. +- `context_report_changed` — `{ workspaceId, report: ContextReport | null }`, full-report + reconciliation, mirroring `suggested_tasks_changed`. + +There is **no dismiss RPC**: dismissal is device-local, as above. + +`ContextReport` carries the nodes, the typed edges, per-category +`{ estTokens, sharePercent, severity }`, the fixed / conditional / referenced totals, the working +room, and the aggregate severity. + +## Gating, safety and the estimates + +- **Context files get a standing edit-gate exemption.** They live outside the workspace root + (`~/.claude/…`), which the gated-multi-root `resolveEditGate` free/other/outside logic would + otherwise block — and "outside the workspace" is the entire point of this feature. The exemption + is scoped to files the scanner actually resolved, never a blanket unlock. +- **Editing a `global`-scope node confirms first**, naming the consequence: it changes every + project on the machine. +- **The provider comes from the workspace's newest agent, loaded _or_ persisted.** `listAgents()` + only sees agents in memory, so a freshly restarted daemon resolved no provider, returned a `null` + report, and the whole tab read as broken until someone opened a chat. The disk fallback + (`agentStorage.list()`, newest non-archived agent for the workspace) answers the same question + without loading anything — only `systemPrompt` survives there, so the injected figure is a + **floor**. +- **Cache invalidation is a 15 s TTL**, not a `file.watch.*` live re-scan. Converting an edge + invalidates explicitly and pushes a fresh report; everything else re-reads on the short TTL. +- **MCP tool weight is openai-compat only.** Claude, Codex and OpenCode hand `mcpServers` to a + subprocess and never expose tool schemas in-process, so that row is exact where Otto owns the + payload. Honest beats guessed — but honest is not the same as silent, which is why it is + disclosed rather than dropped (below). +- **Token counts are chars/4.** The differential-measurement calibration (diff a stripped agent's + turn-one input tokens against a normal agent's; the difference is the real fixed tax) has not + been run, so the scanner ships convention-first and calibration multiplies in without structural + change. + +## Cross-references + +- [refine.md](refine.md) — the propose-then-accept loop compaction runs on +- [token-economy.md](token-economy.md) — the five structural multipliers this measures against +- [glossary.md](glossary.md) — spend vs occupancy, and why they never share a readout +- [agent-personalities.md § Memory](agent-personalities.md#memory-accrued-lessons) — the accrued + lessons this tab is the one place to see and manage diff --git a/docs/custom-providers.md b/docs/custom-providers.md new file mode 100644 index 000000000..95c422b49 --- /dev/null +++ b/docs/custom-providers.md @@ -0,0 +1,873 @@ +# Custom Provider Configuration + +Otto supports configuring custom agent providers through `config.json` (located at `$OTTO_HOME/config.json`, typically `~/.otto/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider. + +All provider configuration lives under `agents.providers` in config.json: + +```json +{ + "version": 1, + "agents": { + "providers": { + "provider-id": { ... } + } + } +} +``` + +Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). + +--- + +## Table of Contents + +- [Extending a built-in provider](#extending-a-built-in-provider) +- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) +- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) +- [OpenAI Compatible (local models)](#openai-compatible-local-models) +- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) +- [Remembered endpoints](#remembered-endpoints) +- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider) +- [Custom binary for a provider](#custom-binary-for-a-provider) +- [Disabling a provider](#disabling-a-provider) +- [ACP providers](#acp-providers) +- [Provider override reference](#provider-override-reference) + +--- + +## Extending a built-in provider + +Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions. + +```json +{ + "agents": { + "providers": { + "my-claude": { + "extends": "claude", + "label": "My Claude", + "description": "Claude with custom API endpoint", + "env": { + "ANTHROPIC_API_KEY": "sk-ant-...", + "ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1" + } + } + } + } +} +``` + +Required fields for custom providers: + +- `extends` — which built-in provider to inherit from (or `"acp"`) +- `label` — display name in the UI + +See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example. + +--- + +## Z.AI (Zhipu) coding plan + +[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. + +### Setup + +1. Register at [z.ai](https://z.ai) and subscribe to a coding plan +2. Create an API key from the Z.AI dashboard +3. Add a provider entry in config.json: + +```json +{ + "agents": { + "providers": { + "zai": { + "extends": "claude", + "label": "ZAI", + "env": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", + "API_TIMEOUT_MS": "3000000" + }, + "disallowedTools": ["WebSearch"], + "models": [ + { "id": "glm-4.5-air", "label": "GLM 4.5 Air" }, + { "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true }, + { "id": "glm-5.1", "label": "GLM 5.1" } + ] + } + } + } +} +``` + +### Available models + +| Model | Tier | +| ------------- | ------------------- | +| `glm-5.1` | Advanced (flagship) | +| `glm-5-turbo` | Advanced | +| `glm-4.7` | Standard | +| `glm-4.5-air` | Lightweight | + +### Notes + +- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key +- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic) +- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider +- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors. +- Automated setup is also available: `npx @z_ai/coding-helper` +- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude) + +--- + +## Alibaba Cloud (Qwen) coding plan + +[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models. + +### Setup + +1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region) +2. Subscribe to the Pro plan ($50/month) +3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key +4. Add a provider entry in config.json: + +```json +{ + "agents": { + "providers": { + "qwen": { + "extends": "claude", + "label": "Qwen (Alibaba)", + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-sp-", + "ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + }, + "disallowedTools": ["WebSearch"], + "models": [ + { "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true }, + { "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }, + { "id": "kimi-k2.5", "label": "Kimi K2.5" } + ] + } + } + } +} +``` + +### API endpoints + +| Mode | Base URL | +| ------------------------------- | ----------------------------------------------------------- | +| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | +| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` | + +For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`. + +### Available models + +**Recommended for coding plan:** + +| Model | Notes | +| ------------------ | --------------------------- | +| `qwen3.5-plus` | Vision capable, recommended | +| `qwen3-coder-next` | Optimized for coding | +| `kimi-k2.5` | Vision capable | +| `glm-5` | Zhipu GLM | +| `MiniMax-M3` | MiniMax | + +**Additional models (pay-as-you-go):** +`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash` + +### Notes + +- API keys must be created in the **Singapore region** +- The coding plan is for personal use only in interactive coding tools +- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors. +- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan) + +--- + +## OpenAI Compatible (local models) + +Local inference servers like [LM Studio](https://lmstudio.ai), [Ollama](https://ollama.com), vLLM, and llama.cpp expose an OpenAI-compatible HTTP server instead of a CLI. Otto talks to them **natively** — the daemon connects to the endpoint directly with `extends: "openai-compatible"`. No agent CLI is involved. + +**OpenAI Compatible** ships as a featured preset in the in-app **Add provider** catalog, defaulting to LM Studio's local port (`http://localhost:1234`). Installing it creates the config below; the Server URL and API key are editable afterwards in the provider's settings sheet (Connection section) — point it at Ollama, vLLM, or any other OpenAI-compatible server instead. + +### Setup + +1. Install a local server, e.g. [LM Studio](https://lmstudio.ai) or [Ollama](https://ollama.com), and download the models you want +2. Start the local server (for LM Studio: Developer tab → Start Server, or `lms server start`) +3. Install the preset from Settings → Add provider, or add the entry manually: + +```json +{ + "agents": { + "providers": { + "openai-compatible": { + "extends": "openai-compatible", + "label": "OpenAI Compatible", + "env": { + "OPENAI_BASE_URL": "http://localhost:1234/v1" + } + } + } + } +} +``` + +### How it works + +- **Models are discovered automatically** from `GET {OPENAI_BASE_URL}/models` — whatever the server has downloaded shows up in the model picker. Setting `models` in the config replaces discovery with a static list. +- **Status reflects reachability, not installation.** When the server is running the provider shows Available with the discovered model count; when it isn't, the row shows an error explaining that the endpoint can't be reached. +- Chat turns stream over `POST {OPENAI_BASE_URL}/chat/completions` (SSE). Reasoning deltas (`reasoning_content`) are rendered as thinking output. +- **Function-calling models get a built-in coding toolset** executed by the daemon in the agent's cwd: `read_file`, `list_dir`, `grep_search`, `write_file`, `edit_file`, `run_command`. Tool calls stream as timeline items like any other agent's. +- **Otto's tool catalog is injected too.** Because there is no agent binary to host an MCP client, the daemon injects Otto's agent tools (`browser_*`, `preview_*`, agent management, terminals, schedules, workspace) directly into the model's tool list, so a local model can drive previews and browser verification like Claude Code does. Excluded in `plan` mode (those tools can take actions); the built-in coding tools win on any name collision. +- **Otto tools are permission-gated by class** (`openai-compat-otto-tool-permissions.ts`). CLI providers get prompting from their own permission system in front of the MCP client; here the daemon prompts itself. Read-only tools (`browser_snapshot`, `preview_logs`, `list_agents`, …) never prompt. "Interact" tools (browser clicks/typing/navigation, `preview_start`/`preview_stop`) prompt in `default` mode and auto-approve in `acceptEdits`, like file edits. "Execute" tools (`browser_evaluate`, `browser_upload`, terminal creation and keystrokes, agent creation/mode changes, schedule and worktree mutation — and any unclassified tool) prompt in `default` **and** `acceptEdits`, like shell commands. `bypassPermissions` auto-approves everything. +- **Scope the Otto tools with `ottoToolGroups`.** Omit it for all groups (the default); set it to a subset to keep the prompt small and the model focused. Groups: `preview`, `browser`, `web`, `agents`, `terminals`, `schedules`, `artifacts`, `workspace`. The `web` group gates the built-in `web_search` / `web_fetch` tools (DuckDuckGo search + page fetch); unchecking it hides both from the model. The `artifacts` group gates `create_artifact` — uncheck it to keep the artifact suite out of the model entirely. + + ```json + { + "agents": { + "providers": { + "openai-compatible": { + "extends": "openai-compatible", + "label": "OpenAI Compatible", + "env": { "OPENAI_BASE_URL": "http://localhost:1234/v1" }, + "ottoToolGroups": ["preview", "browser"] + } + } + } + } + ``` + +- **Permission modes** mirror the other providers: `default` (Always Ask — prompts before edits, commands, web fetches, and non-read-only Otto/MCP tools), `acceptEdits` (auto-approves file edits **inside the workspace subtree** — edits outside the cwd, commands, and web fetches still prompt), `plan` (Read Only — only read tools are offered to the model and Otto/MCP tools are withheld; `web_fetch` stays available for research but prompts), and `bypassPermissions` (unattended). The workspace check is lexical (no symlink chasing) — it scopes prompting, it is not a sandbox. +- **`web_fetch` prompts in every mode except `bypassPermissions`.** The read tools never prompt and accept absolute paths, so an unprompted fetch would hand a prompt-injected model a zero-click exfiltration channel (read a secret, smuggle it out in a GET query string). `web_search` talks only to DuckDuckGo, so it stays unprompted. Fetches are SSRF-guarded: redirects are followed manually with every hop re-validated, the connection is pinned to the exact DNS answers that passed validation (defeats low-TTL DNS rebinding), loopback/private/link-local/metadata/mapped-IPv6 ranges are blocked, and bodies are streamed with a hard 1 MB cap instead of buffered. +- **Effort** (reasoning) is a per-model thinking option like every other provider: models advertise Off / Low / Medium / High in `thinkingOptions`, driven by the standard Effort control in the composer, schedule form, and artifact form. Off (the default) omits the `reasoning_effort` parameter entirely, so strict servers are unaffected until you opt in. Custom profile models that declare their own `thinkingOptions` override the defaults. Agents created before the unification persisted the value as a `reasoning_effort` feature select — the daemon still reads (and old clients can still set) that value; see `COMPAT(openaiCompatReasoningFeature)`. +- **Rewind conversation** is supported: the daemon owns this provider's transcript, so rewinding truncates the conversation at the chosen user message. Persistence keeps the full conversation (no message cap), and resume replays the whole history into the timeline — assistant text, reasoning, and reconstructed tool calls with their results — so a resumed agent looks and rewinds the same as a live one. +- **Compaction: manual `/compact [instruction]` plus threshold-based auto-compaction.** Both run the same in-process pipeline: zero-LLM pruning of uneventful/oversized tool outputs first, then a structured handoff summary of everything older than a keep-recent budget; a prior summary is updated incrementally rather than re-summarized. + - **Auto-compaction triggers on measured usage, not on overflow errors.** The daemon compares the server-reported context size (prompt + completion tokens of the last round) against the probed context window and compacts when it crosses the threshold — at turn start (before the new user message joins the conversation, so it always survives verbatim) and between tool rounds within a turn. Detecting overflow _errors_ across heterogeneous servers was deliberately rejected (2026-07 fork review); thresholds avoid ever reaching overflow instead. Consequence: **endpoints that report no context length never auto-compact** — there's no denominator. LM Studio and vLLM report one; plain llama.cpp setups may not. + - **Per-agent control is the "Auto-compact" feature select** (Off / At 50% / … / At 90%, default 80%) in the agent controls. The value persists per agent and survives resume. + - **Provider-level defaults** live in the provider entry: `"compaction": { "autoCompact": false }` defaults new agents to Off, `"thresholdPercent": 50|60|70|80|90` shifts the default trigger, and `"keepRecentTokens": 20000` tunes how much recent conversation stays verbatim through every compaction (manual and auto). Per-agent feature values win over these defaults. + - **Loop protection:** an auto-compaction that fails, or that can't bring usage back under the threshold (e.g. the retained tail alone exceeds it on a small window), pauses auto-compaction and says so in the timeline — otherwise it would re-summarize on every round, burning a model call each time for nothing. It re-arms when usage drops below the threshold again (rewind, manual `/compact`, larger window) or when the user changes the auto-compact setting. Auto-compaction failures never fail the user's turn. + - The other providers auto-compact on their own: Claude Code, Codex, and OpenCode manage it internally and Otto renders their compaction markers (with auto/manual attribution); Pi exposes `/compact` and `/autocompact` plus `.pi/settings.json` `compaction` settings (`enabled`, `reserveTokens`, `keepRecentTokens`), which this provider's settings deliberately mirror. Copilot (ACP) surfaces no compaction signal today. +- **Max tool rounds per turn** is the daemon-owned loop's safety valve. Because the daemon (not the model host) runs the model→tool→model loop for this provider, it caps the number of rounds in a single turn; after that many rounds without a final answer the turn stops with a `Stopped after N tool rounds without a final answer.` error. This most often bites smaller local models that keep calling tools instead of converging. The cap is **50 by default** and is set in the provider settings **Agents** tab (25/50/100/200/500), or in the provider entry as `"maxToolRounds": <1–1000>` (any integer in range, not just the dropdown presets). Edits apply to running chats on their next turn. (Not relevant to CLI/ACP providers — Claude Code, Codex, OpenCode, Pi own their own tool loop inside their own process, so Otto never sees a round boundary to cap.) +- `OPENAI_API_KEY` is optional — set it if your server requires an API key (LM Studio local servers accept requests without one). +- `/v1` is appended to the URL automatically if missing. Remote servers (e.g. LM Studio on another machine over Tailscale) work by pointing `OPENAI_BASE_URL` at that host. +- The same `extends: "openai-compatible"` entry works for any OpenAI-compatible server: Ollama (`http://localhost:11434/v1`), vLLM, llama.cpp server, gateways. + +### MCP servers + +The daemon itself acts as the MCP client for this provider: it connects to the configured servers per agent session, lists their tools, and exposes them to the model as `mcp_{server}_{tool}` functions alongside the built-in coding tools and Otto's catalog. Configure servers in the provider entry: + +```json +{ + "agents": { + "providers": { + "openai-compatible": { + "extends": "openai-compatible", + "label": "OpenAI Compatible", + "env": { "OPENAI_BASE_URL": "http://localhost:1234/v1" }, + "mcpServers": { + "docs": { "type": "stdio", "command": "npx", "args": ["-y", "some-mcp-server"] }, + "tracker": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer " } + } + }, + "mcpToolPermissions": "always-ask" + } + } + } +} +``` + +- **Transports:** `stdio` (daemon spawns the process — scrubbed environment, agent cwd, tree-killed on session close), `http`, and `sse`. +- **Merging:** provider-level `mcpServers` merge with any per-agent `mcpServers` sent at agent create; the per-agent entry wins on a server-name collision. +- **Namespacing:** tool names are always exposed as `mcp_{server}_{tool}` (sanitized to the OpenAI charset), so an MCP server can never shadow `run_command`, `preview_start`, or any other builtin/Otto tool. +- **Permissions (`mcpToolPermissions`):** MCP tools are opaque — the daemon can't know whether one is destructive. + - `"always-ask"` (the default): every MCP tool call prompts in `default` **and** `acceptEdits` modes. + - `"trust-read-only"`: in `acceptEdits` mode, tools whose MCP `readOnlyHint` annotation is true auto-approve; everything else still prompts. The hint is the server's self-declaration, so it is never honored in `default` mode. + - `plan` mode never exposes MCP tools regardless of this setting; `bypassPermissions` auto-approves everything, as with all other tools. +- **Prompts as slash commands:** prompts exposed by connected servers appear in the composer as `/mcp_{server}_{prompt}` commands. The rest of the line maps to the prompt's first declared argument; a failed resolution falls back to sending the typed text as a plain prompt. +- **Failure isolation:** a server that can't be reached is skipped with a one-time warning in the agent timeline — the session keeps working with the remaining tools. +- **Security:** `stdio` entries execute arbitrary commands as the daemon's user — only add servers you trust, exactly as you would for any agent that can run shell commands. Configured header and env values are treated as secrets and redacted from logs, timeline errors, and tool output fed to the model. MCP tool results are capped at 30k characters. + +### Current limitations + +- Tool use requires a model that supports OpenAI function calling; models without it fall back to plain chat (the `tools` payload is ignored by the server or the model simply never calls them). +- MCP tool/prompt sets are snapshotted when the session connects; `list_changed` notifications are not yet handled. +- Multi-argument MCP prompts receive only their first declared argument from the slash-command line. +- Custom providers can be removed from the UI (provider settings → Remove provider) on daemons with the `providerRemove` feature. + +--- + +## Codex with a custom OpenAI-compatible endpoint + +Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. + +Otto passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. + +### Setup + +```json +{ + "agents": { + "providers": { + "my-codex": { + "extends": "codex", + "label": "My Codex", + "description": "Codex via custom OpenAI-compatible endpoint", + "env": { + "OPENAI_API_KEY": "sk-...", + "OPENAI_BASE_URL": "https://custom-relay.example.com" + }, + "models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }] + } + } + } +} +``` + +### What Otto wires up + +Under the hood, for each custom Codex provider Otto injects this into Codex's config: + +```toml +model_provider = "my-codex" + +[model_providers.my-codex] +name = "My Codex" +base_url = "https://custom-relay.example.com/v1" +wire_api = "responses" +env_key = "OPENAI_API_KEY" +requires_openai_auth = false +``` + +- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Otto appends `/v1`. Trailing slashes are stripped. +- `wire_api` — always `"responses"` (OpenAI Responses API protocol). +- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Otto passes through. +- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow. + +### Notes + +- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route. +- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Otto does not discover them automatically for Codex. +- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app. +- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider). + +--- + +## Remembered endpoints + +Saving the Connection section of a provider's settings sheet also **remembers** that endpoint — the Server URL together with the API key it was saved with. The remembered entries appear at the top of the Server URL dropdown (above the shipped presets), and picking one restores its credential too, so switching a provider between endpoints is one pick instead of re-typing a key. + +- **Pooled by env-var family, not by provider.** Entries saved from any `openai-compatible` or `codex` provider share the `OPENAI_BASE_URL` / `OPENAI_API_KEY` pool; entries saved from `extends: "claude"` providers share the `ANTHROPIC_BASE_URL` pool. An endpoint you saved under one custom provider is offered by every other provider in the same family. +- **Host-scoped.** They persist in `config.json` under `agents.savedProviderEndpoints`, so the list follows the daemon — the same endpoints are one tap away from the phone. +- **Re-saving a URL updates it in place** rather than piling up copies, so rotating a key just refreshes the remembered one. Twelve entries are kept per family; the least recently saved is evicted past that. +- **Forget this endpoint** (under the Server URL field, shown when the current URL is a remembered one) drops the saved copy. It does not change what the provider is pointed at. +- Credentials are stored in the clear, exactly like the live `agents.providers..env.OPENAI_API_KEY` they were copied from. This is a convenience list over values `config.json` already holds — treat the file accordingly. +- Requires a daemon advertising `features.savedProviderEndpoints` (v0.6.5+). Against an older host the dropdown shows the built-in presets only. + +Example on disk: + +```json +{ + "agents": { + "savedProviderEndpoints": [ + { + "id": "OPENAI_BASE_URL::http://localhost:1234/v1", + "baseUrlKey": "OPENAI_BASE_URL", + "apiKeyKey": "OPENAI_API_KEY", + "baseUrl": "http://localhost:1234/v1", + "apiKey": "lm-studio", + "savedAt": 1763500000000 + } + ] + } +} +``` + +--- + +## Multiple profiles for the same provider + +You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment. + +Example: two different Anthropic accounts as separate profiles: + +```json +{ + "agents": { + "providers": { + "claude-work": { + "extends": "claude", + "label": "Claude (Work)", + "description": "Work Anthropic account", + "env": { + "ANTHROPIC_API_KEY": "sk-ant-work-..." + } + }, + "claude-personal": { + "extends": "claude", + "label": "Claude (Personal)", + "description": "Personal Anthropic account", + "env": { + "ANTHROPIC_API_KEY": "sk-ant-personal-..." + } + } + } + } +} +``` + +Each profile appears as a separate provider in the Otto app. You can select which one to use when launching an agent. + +You can also combine profiles with model overrides to pin specific models per profile: + +```json +{ + "agents": { + "providers": { + "claude-fast": { + "extends": "claude", + "label": "Claude (Fast)", + "models": [{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }] + }, + "claude-smart": { + "extends": "claude", + "label": "Claude (Smart)", + "models": [{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }] + } + } + } +} +``` + +--- + +## Custom binary for a provider + +Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments. + +### Override a built-in provider's binary + +```json +{ + "agents": { + "providers": { + "claude": { + "command": ["/opt/claude-nightly/claude"] + } + } + } +} +``` + +### Use a custom wrapper script + +```json +{ + "agents": { + "providers": { + "claude": { + "command": ["/usr/local/bin/my-claude-wrapper", "--verbose"] + } + } + } +} +``` + +### Custom binary on a derived provider + +```json +{ + "agents": { + "providers": { + "my-codex": { + "extends": "codex", + "label": "Codex (Custom Build)", + "command": ["/home/user/codex-dev/target/release/codex"] + } + } + } +} +``` + +The `command` array completely replaces the default command for that provider. The binary must exist on the system — Otto checks for its availability and will mark the provider as unavailable if not found. + +### Pi-compatible forks with their own session directory + +OMP already ships as a built-in provider option. It is disabled by default; enable it with: + +```json +{ + "agents": { + "providers": { + "omp": { "enabled": true } + } + } +} +``` + +For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory: + +```json +{ + "agents": { + "providers": { + "my-pi-fork": { + "extends": "pi", + "label": "My Pi Fork", + "command": ["my-pi-fork"], + "params": { + "sessionDir": "~/.my-pi-fork/sessions" + } + } + } + } +} +``` + +The session directory is used only for importing sessions that were started outside Otto. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. + +--- + +## Disabling a provider + +Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI. + +```json +{ + "agents": { + "providers": { + "copilot": { "enabled": false }, + "codex": { "enabled": false } + } + } +} +``` + +This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`. + +--- + +## ACP providers + +The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Otto as a custom provider. + +ACP agents communicate over JSON-RPC 2.0 on stdio. Otto spawns the agent process and talks to it through stdin/stdout. + +Otto also ships an in-app provider catalog (Settings → Add provider) for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. ACP catalog entries create the same `extends: "acp"` provider config shown below. The catalog also carries featured endpoint presets (e.g. [OpenAI Compatible](#openai-compatible-local-models)) that use the native `extends: "openai-compatible"` provider type instead. + +### Adding a generic ACP provider + +Set `extends: "acp"` and provide a `command`: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent-binary", "--acp"], + "env": { + "MY_API_KEY": "..." + } + } + } + } +} +``` + +Required fields for ACP providers: + +- `extends: "acp"` +- `label` +- `command` — the command to spawn the agent process (must support ACP over stdio) + +Otto tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Otto's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent", "acp"], + "params": { + "supportsMcpServers": false + } + } + } + } +} +``` + +### Generic ACP diagnostics + +Otto diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status. + +For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists. + +ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen. + +### Example: Google Gemini CLI + +[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag. + +1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli) +2. Authenticate with Google (Gemini CLI handles its own auth) +3. Add to config.json: + +```json +{ + "agents": { + "providers": { + "gemini": { + "extends": "acp", + "label": "Google Gemini", + "command": ["gemini", "--acp"] + } + } + } +} +``` + +Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md) + +### Example: Hermes (Nous Research) + +[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand. + +1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash` +2. Install ACP support: `pip install -e '.[acp]'` +3. Configure Hermes credentials in `~/.hermes/` +4. Add to config.json: + +```json +{ + "agents": { + "providers": { + "hermes": { + "extends": "acp", + "label": "Hermes", + "description": "Nous Research self-improving AI agent", + "command": ["hermes", "acp"] + } + } + } +} +``` + +Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp) + +### How ACP providers work in Otto + +When you launch an agent with an ACP provider: + +1. Otto spawns the process using the configured `command` +2. Sends an `initialize` JSON-RPC request over stdin +3. The agent responds with its capabilities, available modes, and models +4. Otto creates a session and sends prompts through the ACP protocol +5. The agent streams responses, tool calls, and permission requests back over stdout + +Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent", "--acp"], + "models": [ + { "id": "fast-model", "label": "Fast", "isDefault": true }, + { "id": "smart-model", "label": "Smart" } + ] + } + } + } +} +``` + +Profile models (defined in config.json) completely replace runtime-discovered models when present. + +If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead. + +Example: add an experimental model while keeping every model the provider discovers at runtime: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent", "--acp"], + "additionalModels": [ + { "id": "experimental-model", "label": "Experimental", "isDefault": true } + ] + } + } + } +} +``` + +Example: relabel a discovered model without replacing the full list: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent", "--acp"], + "additionalModels": [{ "id": "provider/model-id", "label": "My Preferred Label" }] + } + } + } +} +``` + +When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place. + +--- + +## Provider override reference + +Every entry under `agents.providers` accepts these fields: + +| Field | Type | Required | Description | +| ------------------ | ------------------------- | ----------------- | ------------------------------------------------------------------ | +| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` | +| `label` | `string` | Yes (custom only) | Display name in the UI | +| `description` | `string` | No | Short description shown in the UI | +| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | +| `env` | `Record` | No | Environment variables to set for the agent process | +| `params` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | +| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | +| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) | +| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | +| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) | +| `order` | `number` | No | Sort order in the provider list | + +### Model definition + +Each entry in the `models` array: + +| Field | Type | Required | Description | +| ----------------- | ------------------ | -------- | ------------------------------------- | +| `id` | `string` | Yes | Model identifier sent to the provider | +| `label` | `string` | Yes | Display name in the UI | +| `description` | `string` | No | Short description | +| `isDefault` | `boolean` | No | Mark as the default model selection | +| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels | + +### Thinking option + +| Field | Type | Required | Description | +| ------------- | --------- | -------- | ----------------------------------- | +| `id` | `string` | Yes | Thinking option identifier | +| `label` | `string` | Yes | Display name | +| `description` | `string` | No | Short description | +| `isDefault` | `boolean` | No | Mark as the default thinking option | + +### Claude settings.json model discovery + +The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Otto reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. + +This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Otto. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. + +### Gotcha: `extends: "claude"` with third-party endpoints + +When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors. + +Use `disallowedTools` to disable unsupported tools: + +```json +{ + "agents": { + "providers": { + "my-proxy": { + "extends": "claude", + "label": "My Proxy", + "env": { + "ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1" + }, + "disallowedTools": ["WebSearch"] + } + } + } +} +``` + +### Valid `extends` values + +Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp` + +Special values: + +- `acp` — creates a generic ACP provider (requires `command`) +- `openai-compatible` — served natively by the daemon against an OpenAI-compatible HTTP endpoint (requires `env.OPENAI_BASE_URL`; see [OpenAI Compatible](#openai-compatible-local-models)) + +### Full example + +A config.json with multiple custom providers: + +```json +{ + "version": 1, + "agents": { + "providers": { + "copilot": { "enabled": false }, + + "zai": { + "extends": "claude", + "label": "ZAI", + "env": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", + "API_TIMEOUT_MS": "3000000" + }, + "disallowedTools": ["WebSearch"], + "models": [ + { "id": "glm-4.5-air", "label": "GLM 4.5 Air" }, + { "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true }, + { "id": "glm-5.1", "label": "GLM 5.1" } + ] + }, + + "qwen": { + "extends": "claude", + "label": "Qwen (Alibaba)", + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-sp-", + "ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" + }, + "disallowedTools": ["WebSearch"], + "models": [ + { "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true }, + { "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" } + ] + }, + + "gemini": { + "extends": "acp", + "label": "Google Gemini", + "command": ["gemini", "--acp"] + }, + + "hermes": { + "extends": "acp", + "label": "Hermes", + "command": ["hermes", "acp"] + } + } + } +} +``` diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 000000000..46d11527a --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,584 @@ +# Data Model + +Otto uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries. + +Server-side stores live under `$OTTO_HOME` (defaults to `~/.otto`), with one exception: artifacts are stored inside the project directory at `{projectCwd}/.otto/artifacts/` (see [Artifacts](#8-artifacts)). + +## Store Surface Rules + +Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface. + +--- + +## Directory layout + +``` +$OTTO_HOME/ +├── config.json # Daemon configuration +├── server-id # Stable daemon identifier (plain text, "srv_") +├── daemon-keypair.json # E2EE keypair for relay (mode 0600) +├── otto.pid # Daemon PID lock file +├── daemon.log # Default log file (path configurable) +├── agents/ +│ └── {sanitized-cwd}/ +│ └── {agentId}.json # One file per agent +├── schedules/ +│ └── {scheduleId}.json # One file per schedule +├── chat/ +│ └── rooms.json # All rooms + messages +├── loops/ +│ └── loops.json # All loop records +├── projects/ +│ ├── projects.json # Project registry +│ └── workspaces.json # Workspace registry +├── runtime/ +│ └── managed-processes/ +│ └── {recordId}.json # Helper processes owned by Otto; reconciled on daemon bootstrap +├── push-tokens.json # Expo push notification tokens +└── activity-stats.json # Daemon-wide usage counters (see docs/activity-stats.md) +``` + +The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place. + +--- + +## 1. Agent Record + +**Path:** `$OTTO_HOME/agents/{project-dir}/{agentId}.json` + +Each agent is stored as a separate JSON file, grouped by project directory. + +| Field | Type | Description | +| -------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | UUID, primary key | +| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | +| `cwd` | `string` | Working directory the agent operates in | +| `workspaceId` | `string?` | Owning workspace id — the single source of ownership. Every agent is stamped with one at create time; legacy cwd-only records are backfilled once by `migrations/backfill-workspace-id.migration.ts` (the only place a cwd→id mapping exists). Runtime code never infers ownership or status from cwd: status is computed per `workspaceId`, and same-cwd siblings are independent. | +| `createdAt` | `string` (ISO 8601) | Creation timestamp | +| `updatedAt` | `string` (ISO 8601) | Last update timestamp | +| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | +| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | +| `title` | `string?` | User-visible title | +| `labels` | `Record` | Key-value labels (default `{}`). `otto.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [chat-lifecycle.md](./chat-lifecycle.md) | +| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | +| `lastModeId` | `string?` | Last active mode ID | +| `config` | `SerializableConfig?` | Agent session configuration (see below) | +| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | +| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | +| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | +| `lastError` | `string?` (nullable) | Last error message, if any | +| `requiresAttention` | `boolean?` | Whether the agent needs user attention | +| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | +| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | +| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | + +### Nested: SerializableConfig + +| Field | Type | Description | +| ------------------ | -------------------------- | ---------------------------- | +| `title` | `string?` | Configured title | +| `modeId` | `string?` | Configured mode | +| `model` | `string?` | Configured model | +| `thinkingOptionId` | `string?` | Thinking/reasoning level | +| `featureValues` | `Record?` | Feature preference overrides | +| `extra` | `Record?` | Provider-specific config | +| `systemPrompt` | `string?` | Custom system prompt | +| `mcpServers` | `Record?` | MCP server configurations | + +### Nested: RuntimeInfo + +| Field | Type | Description | +| ------------------ | -------------------------- | ------------------------------ | +| `provider` | `string` | Active provider | +| `sessionId` | `string?` | Active session ID | +| `model` | `string?` | Active model | +| `thinkingOptionId` | `string?` | Active thinking option | +| `modeId` | `string?` | Active mode | +| `extra` | `Record?` | Provider-specific runtime data | + +### Nested: PersistenceHandle + +| Field | Type | Description | +| -------------- | ---------------------- | --------------------------------------------------------------------- | +| `provider` | `string` | Provider that owns the session | +| `sessionId` | `string` | Session ID for resumption | +| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) | +| `metadata` | `Record?` | Extra metadata | + +### Nested: AgentFeature (discriminated union on `type`) + +**Toggle:** + +| Field | Type | +| ------------- | ---------- | +| `type` | `"toggle"` | +| `id` | `string` | +| `label` | `string` | +| `description` | `string?` | +| `tooltip` | `string?` | +| `icon` | `string?` | +| `value` | `boolean` | + +**Select:** + +| Field | Type | +| ------------- | --------------------- | +| `type` | `"select"` | +| `id` | `string` | +| `label` | `string` | +| `description` | `string?` | +| `tooltip` | `string?` | +| `icon` | `string?` | +| `value` | `string \| null` | +| `options` | `AgentSelectOption[]` | + +--- + +## Runtime-only Terminal Sessions + +Terminals are live daemon state, not persisted JSON records. A terminal carries a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but contribute to no workspace status. + +Terminal activity contributes to the workspace status bucket **per `workspaceId`**: a working terminal drives `running` onto the workspace it carries only. Same-`cwd` siblings are untouched; terminal visibility is likewise `workspaceId`-scoped. + +--- + +## 2. Daemon Configuration + +**Path:** `$OTTO_HOME/config.json` + +Single file, validated with `PersistedConfigSchema`. + +``` +{ + version: 1, + daemon: { + listen: "127.0.0.1:6868", + hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load + trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs + mcp: { enabled: boolean, injectIntoAgents: boolean }, + appendSystemPrompt: string, // appended to supported provider system/developer prompts + cors: { allowedOrigins: string[] }, + relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, + auth: { password: string } // bcrypt hash, optional + }, + app: { + baseUrl: string + }, + worktrees?: { + root?: string // optional root for new worktrees; defaults to $OTTO_HOME/worktrees + }, + providers: { + openai: { + apiKey?: string, + baseUrl?: string, + stt?: { apiKey?: string, baseUrl?: string }, + tts?: { apiKey?: string, baseUrl?: string } + }, + local: { modelsDir: string } + }, + agents: { + // ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the + // current shape on load via `migrateProviderSettings`. Custom provider IDs must declare + // `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`. + providers: Record, + metadataGeneration: { + providers: [{ provider, model?, thinkingOptionId? }] + } + }, + features: { + dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, + voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } + }, + log: { + level, format, + console: { level, format }, + file: { level, path, rotate: { maxSize, maxFiles } } + } +} +``` + +All fields are optional with sensible defaults. + +`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Otto falls through to dynamically discovered defaults and finally the current selection when available. + +Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. + +Set these to select OpenAI instead of local speech: + +| Env var | Applies to | +| ----------------------------- | ------------------------------- | +| `OTTO_VOICE_STT_PROVIDER` | Voice mode STT provider | +| `OTTO_DICTATION_STT_PROVIDER` | Composer dictation STT provider | +| `OTTO_VOICE_TTS_PROVIDER` | Voice mode TTS provider | + +OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints: + +```json +{ + "providers": { + "openai": { + "stt": { + "apiKey": "sk-...", + "baseUrl": "https://stt.example.com/v1" + }, + "tts": { + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1" + } + } + } +} +``` + +`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Otto OpenAI speech features, not to Codex or other OpenAI-backed tools. + +Otto uses these paths under the configured OpenAI base URL: + +- dictation STT: `/v1/audio/transcriptions` +- voice mode STT: `/v1/audio/transcriptions` +- voice mode TTS: `/v1/audio/speech` + +--- + +## 3. Schedule + +**Path:** `$OTTO_HOME/schedules/{id}.json` + +One file per schedule. ID is 8 hex characters. + +| Field | Type | Description | +| ------------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | 8-char hex ID | +| `name` | `string?` | Human-readable name | +| `prompt` | `string` | The prompt to send | +| `cadence` | `ScheduleCadence` | Timing (see below) | +| `target` | `ScheduleTarget` | What to run (see below) | +| `status` | `"active" \| "paused" \| "completed"` | Current state | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution | +| `lastRunAt` | `string?` (ISO 8601) | Last execution time | +| `lastRunStatus` | `"succeeded" \| "failed"?` | Outcome of the last run | +| `lastRunError` | `string?` | Error message from the last run | +| `lastRunPersonalityName` | `string?` (nullable) | Personality that executed the last run (null = none); mirrors the run-level field so `ScheduleSummary` can show the executor without loading `runs` | +| `lastRunProvider` | `string?` (nullable) | Provider that executed the last run | +| `lastRunModel` | `string?` (nullable) | Model that executed the last run | +| `pausedAt` | `string?` (ISO 8601) | When paused | +| `expiresAt` | `string?` (ISO 8601) | Auto-expire time | +| `maxRuns` | `number?` | Max executions before completing | +| `runs` | `ScheduleRun[]` | Execution history | + +### Nested: ScheduleCadence (discriminated union on `type`) + +- `{ type: "every", everyMs: number }` — interval in milliseconds +- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence + +### Nested: ScheduleTarget (discriminated union on `type`) + +- `{ type: "agent", agentId: string }` — send to existing agent +- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, featureValues?, extra?, systemPrompt?, mcpServers? } }` — create a new agent + +### Nested: ScheduleRun + +| Field | Type | Description | +| ----------------- | -------------------------------------- | ------------------------------------------------ | +| `id` | `string` | Run ID | +| `scheduledFor` | `string` (ISO 8601) | Intended execution time | +| `startedAt` | `string` (ISO 8601) | | +| `endedAt` | `string?` (ISO 8601) | | +| `status` | `"running" \| "succeeded" \| "failed"` | | +| `agentId` | `string?` (UUID) | Agent used for this run | +| `personalityName` | `string?` (nullable) | Personality that executed this run (null = none) | +| `provider` | `string?` (nullable) | Provider that executed this run | +| `model` | `string?` (nullable) | Model that executed this run | +| `output` | `string?` | Agent output text | +| `error` | `string?` | Error message if failed | + +--- + +## 4. Chat + +**Path:** `$OTTO_HOME/chat/rooms.json` + +Single file containing all rooms and messages. + +```json +{ + "rooms": [ ... ], + "messages": [ ... ] +} +``` + +### ChatRoom + +| Field | Type | Description | +| ----------- | ------------------- | ----------------------------------- | +| `id` | `string` (UUID) | | +| `name` | `string` | Unique room name (case-insensitive) | +| `purpose` | `string?` | Room description | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | Updated on each new message | + +### ChatMessage + +| Field | Type | Description | +| ------------------ | ------------------- | ----------------------------------- | +| `id` | `string` (UUID) | | +| `roomId` | `string` | FK to ChatRoom.id | +| `authorAgentId` | `string` | Agent ID of the author | +| `body` | `string` | Message text (supports `@mentions`) | +| `replyToMessageId` | `string?` | FK to another ChatMessage.id | +| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs | +| `createdAt` | `string` (ISO 8601) | | + +--- + +## 5. Loop + +**Path:** `$OTTO_HOME/loops/loops.json` + +Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry. + +| Field | Type | Description | +| ----------------------- | --------------------------------------------------- | ------------------------------------------ | +| `id` | `string` | 8-char UUID prefix | +| `name` | `string?` | Human-readable name | +| `prompt` | `string` | Worker prompt | +| `cwd` | `string` | Working directory | +| `provider` | `string` | Default provider | +| `model` | `string?` | Default model | +| `modeId` | `string?` | Default mode ID | +| `workerProvider` | `string?` | Override provider for workers | +| `workerModel` | `string?` | Override model for workers | +| `verifierProvider` | `string?` | Override provider for verifiers | +| `verifierModel` | `string?` | Override model for verifiers | +| `verifierModeId` | `string?` | Override mode ID for verifiers | +| `verifyPrompt` | `string?` | LLM verification prompt | +| `verifyChecks` | `string[]` | Shell commands to run as checks | +| `archive` | `boolean` | Whether to archive worker agents after use | +| `sleepMs` | `number` | Delay between iterations (ms) | +| `maxIterations` | `number?` | Cap on iterations | +| `maxTimeMs` | `number?` | Total time budget (ms) | +| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `startedAt` | `string` (ISO 8601) | | +| `completedAt` | `string?` (ISO 8601) | | +| `stopRequestedAt` | `string?` (ISO 8601) | | +| `iterations` | `LoopIteration[]` | | +| `logs` | `LoopLogEntry[]` | | +| `nextLogSeq` | `number` | Monotonic log sequence counter | +| `activeIteration` | `number?` | Currently executing iteration index | +| `activeWorkerAgentId` | `string?` | Currently running worker agent | +| `activeVerifierAgentId` | `string?` | Currently running verifier agent | + +### Nested: LoopIteration + +| Field | Type | Description | +| ------------------- | --------------------------------------------------- | ------------------------ | +| `index` | `number` | 1-based iteration index | +| `workerAgentId` | `string?` | Agent ID of the worker | +| `workerStartedAt` | `string` (ISO 8601) | | +| `workerCompletedAt` | `string?` (ISO 8601) | | +| `verifierAgentId` | `string?` | Agent ID of the verifier | +| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | +| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | | +| `failureReason` | `string?` | | +| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results | +| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result | + +### Nested: LoopLogEntry + +| Field | Type | +| ----------- | ---------------------------------------------------- | +| `seq` | `number` (monotonic) | +| `timestamp` | `string` (ISO 8601) | +| `iteration` | `number?` | +| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` | +| `level` | `"info" \| "error"` | +| `text` | `string` | + +### Nested: LoopVerifyCheckResult + +| Field | Type | +| ------------- | ------------------- | +| `command` | `string` | +| `exitCode` | `number` | +| `passed` | `boolean` | +| `stdout` | `string` | +| `stderr` | `string` | +| `startedAt` | `string` (ISO 8601) | +| `completedAt` | `string` (ISO 8601) | + +### Nested: LoopVerifyPromptResult + +| Field | Type | +| ----------------- | ------------------- | +| `passed` | `boolean` | +| `reason` | `string` | +| `verifierAgentId` | `string?` | +| `startedAt` | `string` (ISO 8601) | +| `completedAt` | `string` (ISO 8601) | + +--- + +## 6. Project Registry + +**Path:** `$OTTO_HOME/projects/projects.json` + +Array of project records. + +| Field | Type | Description | +| ------------- | --------------------------- | ---------------------------------------- | +| `projectId` | `string` | Primary key | +| `rootPath` | `string` | Filesystem root of the project | +| `kind` | `"git" \| "non_git"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | + +Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad +states by moving workspaces from duplicate path-keyed projects onto the canonical project, +preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the +emptied duplicate. + +--- + +## 7. Workspace Registry + +**Path:** `$OTTO_HOME/projects/workspaces.json` + +Array of workspace records. A workspace is a specific working directory within a project. + +| Field | Type | Description | +| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | +| `projectId` | `string` | FK to Project.projectId | +| `cwd` | `string` | Filesystem path | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | +| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | +| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | +| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | + +> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids. + +`projectId` is still a real FK: workspace records should have a matching project record. Read-only +history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot +blank the whole History screen, but mutation paths should repair or remove the orphaned state rather +than treating it as valid. + +**Two workspaces may share a `cwd`, but nothing creates one implicitly.** A same-directory sibling +exists only because the user explicitly asked for it. Every path that needs a workspace for a +directory adopts the one the request came from: `create_agent_request` and `import_agent_request` +both carry an optional `workspaceId` and honour it, and a request without one resolves by directory +(`findOrCreateWorkspaceForDirectory`) rather than minting. Import used to mint unconditionally, which +left a duplicate workspace in the sidebar owning the chat the user had just imported in place. + +--- + +## 8. Artifacts + +**Path:** `{projectCwd}/.otto/artifacts/{artifactId}.json` + `{artifactId}.html` + +The one store that lives **inside the project directory**, not under `$OTTO_HOME` — artifacts belong to the project and travel with the checkout. Each artifact is a metadata JSON file plus a sibling self-contained HTML file, written atomically. `ArtifactStore` (`packages/server/src/server/artifact/artifact-store.ts`) is constructed per project cwd; `ArtifactWatcher` watches the directory so externally-edited artifacts refresh in connected clients. Generated HTML is checked by `html-validator.ts` before an artifact is marked `ready`. + +Generation is agent-based, not a bespoke completion call: `ArtifactService` (`artifact-service.ts`) spawns a normal Otto agent (`AgentManager.createAgent`) with the user's chosen provider/model and a dedicated system prompt (`artifact-prompt.ts`) that constrains output to a **single, completely self-contained HTML file** — all CSS/JS inline, mirroring Claude Code's artifact discipline. The run streams as a live generation log while `status` is `generating`, its provenance lands in the `generationAgentId`/`generationProvider`/`generationModel` fields below, and cancel interrupts the agent run. Iteration ("re-prompt to modify") is the same flow re-pointed at an existing artifact: the service resets it to `generating` and spawns a fresh generation agent that rewrites the HTML in place, which the watcher then pushes to any open artifact tab. + +Agents can create artifacts too, via the `create_artifact` Otto tool (`packages/server/src/server/agent/tools/otto-tools.ts`): it defaults provider/model/thinking/project from the calling agent, returns immediately with `status: "generating"` (the description must be self-contained — the generation agent cannot see the calling conversation), and broadcasts `artifact.created.notification` so every connected client sees it live. The tool runs against a daemon-global `ArtifactService` instance wired in `bootstrap.ts`; client-initiated artifact RPCs go through each session's own service, both sharing the same file-backed store. + +Metadata (`ArtifactMetadataSchema`, `packages/protocol/src/artifacts/types.ts`): + +| Field | Type | Description | +| ------------------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Primary key; also the file basename | +| `name`, `description` | `string` | User/agent-supplied | +| `projectId` | `string` | Owning project | +| `filePath` | `string` | Path of the HTML file | +| `kind` | `"html"` | Only kind today | +| `starred` | `boolean` | | +| `status` | `"generating" \| "ready" \| "error"` | Generation lifecycle | +| `createdAt`, `updatedAt` | `string` (ISO 8601) | | +| `generationAgentId`, `generationProvider`, `generationModel` | `string \| null` | Provenance of the generating agent run | +| `generationPersonalityName` | `string \| null` (optional) | Personality that generated (last generated) the artifact; null/absent when none. Shown on the card's executor line alongside provider/model | +| `errorMessage` | `string \| null` | Set when `status === "error"` | + +Availability is gated by the `artifacts` capability flag in `server_info.features.*` (COMPAT(artifacts), added v0.4.1). + +--- + +## 9. Push Token Store + +**Path:** `$OTTO_HOME/push-tokens.json` + +```json +{ + "tokens": ["ExponentPushToken[...]", ...] +} +``` + +Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename. + +--- + +## 10. Daemon meta files + +These small files are not validated as full Zod schemas but are persisted under `$OTTO_HOME` for daemon identity and runtime coordination. + +| Path | Format | Notes | +| --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `server-id` | Plain text, e.g. `srv_` | Stable per-`$OTTO_HOME` daemon ID. Overridable via `OTTO_SERVER_ID` env. | +| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. | +| `otto.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$OTTO_HOME`. | +| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. | + +--- + +## Client-side stores (App) + +These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem. + +### Keying convention: directory-backed vs workspace-owned + +Right-sidebar client state splits on whether it is determined by the directory or owned by the workspace (two workspaces can share one `cwd`). The split is enforced by the cache key, so changing a key changes the sharing semantics — see [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned) for the full table. + +- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores. +- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@otto:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path. + +### Draft Store + +**AsyncStorage key:** `otto-drafts` (version 2) + +```typescript +{ + drafts: Record, + createModalDraft: DraftRecord | null +} +``` + +### Attachment Store (Web) + +**IndexedDB database:** `otto-attachment-bytes`, object store: `attachments` + +Stores binary attachment blobs keyed by attachment ID. + +### AttachmentMetadata + +| Field | Type | Description | +| ------------- | --------- | ------------------------------ | +| `id` | `string` | Unique attachment ID | +| `mimeType` | `string` | MIME type | +| `storageType` | `string` | Storage backend identifier | +| `storageKey` | `string` | Key within the storage backend | +| `createdAt` | `number` | Epoch ms | +| `fileName` | `string?` | Original filename | +| `byteSize` | `number?` | Size in bytes | diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 000000000..bf5f4c308 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,266 @@ +# Design + +Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`. + +--- + +## 1. Character + +Otto is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar. + +The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_. + +Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong. + +--- + +## 2. Component reuse + +A semantic element used in three or more places is a primitive. One of a kind is a screen. + +Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`. + +A pressable styled to look like a button is wrong; the button is ` + + + ), + [handleAdd, onClose], + ); + + return ( + + + {rows.length === 0 ? ( + + No inputs yet. Nodes reference inputs as {"{{inputs.key}}"} in their prompts, or bind + one via prompt-from-input on the node. + + ) : null} + {rows.map((row) => ( + + ))} + + + ); +} + +function GraphInputRow({ + row, + onPatch, + onRemove, +}: { + row: GraphInputRowState; + onPatch: (uid: string, patch: Partial) => void; + onRemove: (uid: string) => void; +}): ReactElement { + const handleKeyChange = useCallback( + (value: string) => onPatch(row.uid, { key: value.trim() }), + [onPatch, row.uid], + ); + const handleLabelChange = useCallback( + (value: string) => onPatch(row.uid, { label: value }), + [onPatch, row.uid], + ); + const handleMultilineChange = useCallback( + (value: boolean) => onPatch(row.uid, { multiline: value }), + [onPatch, row.uid], + ); + const handleRequiredChange = useCallback( + (value: boolean) => onPatch(row.uid, { required: value }), + [onPatch, row.uid], + ); + const handleRemovePress = useCallback(() => onRemove(row.uid), [onRemove, row.uid]); + + return ( + + + + + {/* AdaptiveTextInput renders uncontrolled from initialValue (RN + flicker workaround) — omitting it shows an EMPTY field even + when data exists. Rows are uid-keyed, so the one-shot seed is + correct per mount. */} + + + + + + + + + + + + Multiline + + + + Required + + + + + + ); +} + +export const OrchestrationGraphPanel = withUnistyles(OrchestrationGraphPanelInner, (theme) => ({ + canvasTheme: buildGraphCanvasTheme(theme), +})); + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + // Icon-only actions keep this to a single compact row — the canvas is the + // point of this tab, not its chrome. + toolbar: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + toolbarButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + minWidth: { xs: 32, sm: 32, md: 26 }, + height: { xs: 32, sm: 32, md: 26 }, + borderRadius: theme.borderRadius.base, + flexShrink: 0, + }, + toolbarButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + toolbarButtonDisabled: { + opacity: 0.4, + backgroundColor: "transparent", + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: "600", + flexShrink: 1, + }, + status: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + toolbarActions: { + flexDirection: "row", + gap: theme.spacing[2], + marginLeft: "auto", + }, + canvasWrap: { + flex: 1, + overflow: "hidden", + }, + loading: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + zIndex: 1, + }, + loadingText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + inputsBody: { + gap: theme.spacing[4], + paddingBottom: theme.spacing[4], + }, + inputsEmpty: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: Math.round(theme.fontSize.sm * 1.5), + }, + inputRow: { + gap: theme.spacing[2], + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + padding: theme.spacing[3], + }, + inputRowFields: { + flexDirection: "row", + gap: theme.spacing[3], + }, + inputRowField: { + flex: 1, + }, + inputRowMeta: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[4], + }, + inputToggle: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + inputToggleLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + inputsFooter: { + flexDirection: "row", + gap: theme.spacing[3], + }, + inputsFooterButton: { + flex: 1, + }, +})); diff --git a/packages/app/src/orchestration-graph/vendor/LICENSE-drawflow.txt b/packages/app/src/orchestration-graph/vendor/LICENSE-drawflow.txt new file mode 100644 index 000000000..642bd8537 --- /dev/null +++ b/packages/app/src/orchestration-graph/vendor/LICENSE-drawflow.txt @@ -0,0 +1,28 @@ +Drawflow — https://github.com/jerosoler/Drawflow + +`drawflow.min.js` in this directory is the upstream distribution bundle, +vendored verbatim and never edited (see projects/orchestration-graphs). Its +build strips the license banner, so the notice MIT requires is kept here +instead. + +MIT License + +Copyright (c) 2020 Jero Soler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/app/src/orchestration-graph/vendor/drawflow.min.d.ts b/packages/app/src/orchestration-graph/vendor/drawflow.min.d.ts new file mode 100644 index 000000000..77a98881a --- /dev/null +++ b/packages/app/src/orchestration-graph/vendor/drawflow.min.d.ts @@ -0,0 +1,103 @@ +// Hand-written surface for the vendored Drawflow bundle (MIT, Jero Soler) — +// only the members the graph designer actually touches. The bundle is frozen +// (patched around, never edited), same treatment Draekz Forge gives it. + +export interface DrawflowConnectionRef { + node: string; + input?: string; + output?: string; +} + +export interface DrawflowExportedNode { + id: number; + name: string; + data: Record; + class: string; + html: string; + inputs: Record; + outputs: Record; + pos_x: number; + pos_y: number; +} + +export interface DrawflowExport { + drawflow: { + Home: { + data: Record; + }; + }; +} + +export default class Drawflow { + constructor(container: HTMLElement); + + reroute: boolean; + draggable_inputs: boolean; + zoom: number; + zoom_min: number; + zoom_max: number; + zoom_value: number; + zoom_last_value: number; + canvas_x: number; + canvas_y: number; + container: HTMLElement; + precanvas: HTMLElement; + connection_selected: unknown; + /** True while the user is dragging a wire out of an output port. */ + connection: boolean; + /** The element the current drag started from (an `.output` while wiring). */ + ele_selected: HTMLElement | null; + /** The in-flight wire's while `connection` is true. */ + connection_ele: SVGElement | null; + + createCurvature( + startX: number, + startY: number, + endX: number, + endY: number, + // Drawflow always passes these; the wrapper's orthogonal override ignores + // them, so callers re-using it to redraw a wire may leave them off. + curvature?: number, + type?: string, + ): string; + zoom_enter(event: WheelEvent): void; + zoom_refresh(): void; + + start(): void; + addNode( + name: string, + inputs: number, + outputs: number, + posX: number, + posY: number, + className: string, + data: Record, + html: string, + ): number; + addConnection( + outputId: number | string, + inputId: number | string, + outputClass: string, + inputClass: string, + ): void; + removeSingleConnection( + outputId: number | string, + inputId: number | string, + outputClass: string, + inputClass: string, + ): boolean; + removeNodeId(id: string): void; + updateConnectionNodes(id: string): void; + export(): DrawflowExport; + clear(): void; + contextmenu(event: MouseEvent): void; + /** Drawflow reads only `type`/`clientX`/`clientY`/`target`, so the wrapper can + * hand it a synthetic event to land a snapped wire on a nearby port. */ + dragEnd(event: { + type: string; + clientX: number; + clientY: number; + target: EventTarget | null; + }): void; + on(event: string, callback: (payload: unknown) => void): boolean; +} diff --git a/packages/app/src/orchestration-graph/vendor/drawflow.min.js b/packages/app/src/orchestration-graph/vendor/drawflow.min.js new file mode 100644 index 000000000..012df79f1 --- /dev/null +++ b/packages/app/src/orchestration-graph/vendor/drawflow.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Drawflow=t():e.Drawflow=t()}("undefined"!=typeof self?self:this,(function(){return function(e){var t={};function n(i){if(t[i])return t[i].exports;var s=t[i]={i:i,l:!1,exports:{}};return e[i].call(s.exports,s,s.exports,n),s.l=!0,s.exports}return n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var s in e)n.d(i,s,function(t){return e[t]}.bind(null,s));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){"use strict";n.r(t),n.d(t,"default",(function(){return i}));class i{constructor(e,t=null,n=null){this.events={},this.container=e,this.precanvas=null,this.nodeId=1,this.ele_selected=null,this.node_selected=null,this.drag=!1,this.reroute=!1,this.reroute_fix_curvature=!1,this.curvature=.5,this.reroute_curvature_start_end=.5,this.reroute_curvature=.5,this.reroute_width=6,this.drag_point=!1,this.editor_selected=!1,this.connection=!1,this.connection_ele=null,this.connection_selected=null,this.canvas_x=0,this.canvas_y=0,this.pos_x=0,this.pos_x_start=0,this.pos_y=0,this.pos_y_start=0,this.mouse_x=0,this.mouse_y=0,this.line_path=5,this.first_click=null,this.force_first_input=!1,this.draggable_inputs=!0,this.useuuid=!1,this.parent=n,this.noderegister={},this.render=t,this.drawflow={drawflow:{Home:{data:{}}}},this.module="Home",this.editor_mode="edit",this.zoom=1,this.zoom_max=1.6,this.zoom_min=.5,this.zoom_value=.1,this.zoom_last_value=1,this.evCache=new Array,this.prevDiff=-1}start(){this.container.classList.add("parent-drawflow"),this.container.tabIndex=0,this.precanvas=document.createElement("div"),this.precanvas.classList.add("drawflow"),this.container.appendChild(this.precanvas),this.container.addEventListener("mouseup",this.dragEnd.bind(this)),this.container.addEventListener("mousemove",this.position.bind(this)),this.container.addEventListener("mousedown",this.click.bind(this)),this.container.addEventListener("touchend",this.dragEnd.bind(this)),this.container.addEventListener("touchmove",this.position.bind(this)),this.container.addEventListener("touchstart",this.click.bind(this)),this.container.addEventListener("contextmenu",this.contextmenu.bind(this)),this.container.addEventListener("keydown",this.key.bind(this)),this.container.addEventListener("wheel",this.zoom_enter.bind(this)),this.container.addEventListener("input",this.updateNodeValue.bind(this)),this.container.addEventListener("dblclick",this.dblclick.bind(this)),this.container.onpointerdown=this.pointerdown_handler.bind(this),this.container.onpointermove=this.pointermove_handler.bind(this),this.container.onpointerup=this.pointerup_handler.bind(this),this.container.onpointercancel=this.pointerup_handler.bind(this),this.container.onpointerout=this.pointerup_handler.bind(this),this.container.onpointerleave=this.pointerup_handler.bind(this),this.load()}pointerdown_handler(e){this.evCache.push(e)}pointermove_handler(e){for(var t=0;t100&&(n>this.prevDiff&&this.zoom_in(),n=n&&(n=parseInt(e)+1)}))})),this.nodeId=n}removeReouteConnectionSelected(){this.dispatch("connectionUnselected",!0),this.reroute_fix_curvature&&this.connection_selected.parentElement.querySelectorAll(".main-path").forEach((e,t)=>{e.classList.remove("selected")})}click(e){if(this.dispatch("click",e),"fixed"===this.editor_mode){if(e.preventDefault(),"parent-drawflow"!==e.target.classList[0]&&"drawflow"!==e.target.classList[0])return!1;this.ele_selected=e.target.closest(".parent-drawflow")}else"view"===this.editor_mode?(null!=e.target.closest(".drawflow")||e.target.matches(".parent-drawflow"))&&(this.ele_selected=e.target.closest(".parent-drawflow"),e.preventDefault()):(this.first_click=e.target,this.ele_selected=e.target,0===e.button&&this.contextmenuDel(),null!=e.target.closest(".drawflow_content_node")&&(this.ele_selected=e.target.closest(".drawflow_content_node").parentElement));switch(this.ele_selected.classList[0]){case"drawflow-node":null!=this.node_selected&&(this.node_selected.classList.remove("selected"),this.node_selected!=this.ele_selected&&this.dispatch("nodeUnselected",!0)),null!=this.connection_selected&&(this.connection_selected.classList.remove("selected"),this.removeReouteConnectionSelected(),this.connection_selected=null),this.node_selected!=this.ele_selected&&this.dispatch("nodeSelected",this.ele_selected.id.slice(5)),this.node_selected=this.ele_selected,this.node_selected.classList.add("selected"),this.draggable_inputs?"SELECT"!==e.target.tagName&&(this.drag=!0):"INPUT"!==e.target.tagName&&"TEXTAREA"!==e.target.tagName&&"SELECT"!==e.target.tagName&&!0!==e.target.hasAttribute("contenteditable")&&(this.drag=!0);break;case"output":this.connection=!0,null!=this.node_selected&&(this.node_selected.classList.remove("selected"),this.node_selected=null,this.dispatch("nodeUnselected",!0)),null!=this.connection_selected&&(this.connection_selected.classList.remove("selected"),this.removeReouteConnectionSelected(),this.connection_selected=null),this.drawConnection(e.target);break;case"parent-drawflow":case"drawflow":null!=this.node_selected&&(this.node_selected.classList.remove("selected"),this.node_selected=null,this.dispatch("nodeUnselected",!0)),null!=this.connection_selected&&(this.connection_selected.classList.remove("selected"),this.removeReouteConnectionSelected(),this.connection_selected=null),this.editor_selected=!0;break;case"main-path":null!=this.node_selected&&(this.node_selected.classList.remove("selected"),this.node_selected=null,this.dispatch("nodeUnselected",!0)),null!=this.connection_selected&&(this.connection_selected.classList.remove("selected"),this.removeReouteConnectionSelected(),this.connection_selected=null),this.connection_selected=this.ele_selected,this.connection_selected.classList.add("selected");const t=this.connection_selected.parentElement.classList;t.length>1&&(this.dispatch("connectionSelected",{output_id:t[2].slice(14),input_id:t[1].slice(13),output_class:t[3],input_class:t[4]}),this.reroute_fix_curvature&&this.connection_selected.parentElement.querySelectorAll(".main-path").forEach((e,t)=>{e.classList.add("selected")}));break;case"point":this.drag_point=!0,this.ele_selected.classList.add("selected");break;case"drawflow-delete":this.node_selected&&this.removeNodeId(this.node_selected.id),this.connection_selected&&this.removeConnection(),null!=this.node_selected&&(this.node_selected.classList.remove("selected"),this.node_selected=null,this.dispatch("nodeUnselected",!0)),null!=this.connection_selected&&(this.connection_selected.classList.remove("selected"),this.removeReouteConnectionSelected(),this.connection_selected=null)}"touchstart"===e.type?(this.pos_x=e.touches[0].clientX,this.pos_x_start=e.touches[0].clientX,this.pos_y=e.touches[0].clientY,this.pos_y_start=e.touches[0].clientY,this.mouse_x=e.touches[0].clientX,this.mouse_y=e.touches[0].clientY):(this.pos_x=e.clientX,this.pos_x_start=e.clientX,this.pos_y=e.clientY,this.pos_y_start=e.clientY),["input","output","main-path"].includes(this.ele_selected.classList[0])&&e.preventDefault(),this.dispatch("clickEnd",e)}position(e){if("touchmove"===e.type)var t=e.touches[0].clientX,n=e.touches[0].clientY;else t=e.clientX,n=e.clientY;if(this.connection&&this.updateConnection(t,n),this.editor_selected&&(i=this.canvas_x+-(this.pos_x-t),s=this.canvas_y+-(this.pos_y-n),this.dispatch("translate",{x:i,y:s}),this.precanvas.style.transform="translate("+i+"px, "+s+"px) scale("+this.zoom+")"),this.drag){e.preventDefault();var i=(this.pos_x-t)*this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom),s=(this.pos_y-n)*this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom);this.pos_x=t,this.pos_y=n,this.ele_selected.style.top=this.ele_selected.offsetTop-s+"px",this.ele_selected.style.left=this.ele_selected.offsetLeft-i+"px",this.drawflow.drawflow[this.module].data[this.ele_selected.id.slice(5)].pos_x=this.ele_selected.offsetLeft-i,this.drawflow.drawflow[this.module].data[this.ele_selected.id.slice(5)].pos_y=this.ele_selected.offsetTop-s,this.updateConnectionNodes(this.ele_selected.id)}if(this.drag_point){i=(this.pos_x-t)*this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom),s=(this.pos_y-n)*this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom);this.pos_x=t,this.pos_y=n;var o=this.pos_x*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom))-this.precanvas.getBoundingClientRect().x*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom)),l=this.pos_y*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom))-this.precanvas.getBoundingClientRect().y*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom));this.ele_selected.setAttributeNS(null,"cx",o),this.ele_selected.setAttributeNS(null,"cy",l);const e=this.ele_selected.parentElement.classList[2].slice(9),c=this.ele_selected.parentElement.classList[1].slice(13),d=this.ele_selected.parentElement.classList[3],a=this.ele_selected.parentElement.classList[4];let r=Array.from(this.ele_selected.parentElement.children).indexOf(this.ele_selected)-1;if(this.reroute_fix_curvature){r-=this.ele_selected.parentElement.querySelectorAll(".main-path").length-1,r<0&&(r=0)}const h=e.slice(5),u=this.drawflow.drawflow[this.module].data[h].outputs[d].connections.findIndex((function(e,t){return e.node===c&&e.output===a}));this.drawflow.drawflow[this.module].data[h].outputs[d].connections[u].points[r]={pos_x:o,pos_y:l};const p=this.ele_selected.parentElement.classList[2].slice(9);this.updateConnectionNodes(p)}"touchmove"===e.type&&(this.mouse_x=t,this.mouse_y=n),this.dispatch("mouseMove",{x:t,y:n})}dragEnd(e){if("touchend"===e.type)var t=this.mouse_x,n=this.mouse_y,i=document.elementFromPoint(t,n);else t=e.clientX,n=e.clientY,i=e.target;if(this.drag&&(this.pos_x_start==t&&this.pos_y_start==n||this.dispatch("nodeMoved",this.ele_selected.id.slice(5))),this.drag_point&&(this.ele_selected.classList.remove("selected"),this.pos_x_start==t&&this.pos_y_start==n||this.dispatch("rerouteMoved",this.ele_selected.parentElement.classList[2].slice(14))),this.editor_selected&&(this.canvas_x=this.canvas_x+-(this.pos_x-t),this.canvas_y=this.canvas_y+-(this.pos_y-n),this.editor_selected=!1),!0===this.connection)if("input"===i.classList[0]||this.force_first_input&&(null!=i.closest(".drawflow_content_node")||"drawflow-node"===i.classList[0])){if(!this.force_first_input||null==i.closest(".drawflow_content_node")&&"drawflow-node"!==i.classList[0])s=i.parentElement.parentElement.id,o=i.classList[1];else{if(null!=i.closest(".drawflow_content_node"))var s=i.closest(".drawflow_content_node").parentElement.id;else var s=i.id;if(0===Object.keys(this.getNodeFromId(s.slice(5)).inputs).length)var o=!1;else var o="input_1"}var l=this.ele_selected.parentElement.parentElement.id,c=this.ele_selected.classList[1];if(l!==s&&!1!==o){if(0===this.container.querySelectorAll(".connection.node_in_"+s+".node_out_"+l+"."+c+"."+o).length){this.connection_ele.classList.add("node_in_"+s),this.connection_ele.classList.add("node_out_"+l),this.connection_ele.classList.add(c),this.connection_ele.classList.add(o);var d=s.slice(5),a=l.slice(5);this.drawflow.drawflow[this.module].data[a].outputs[c].connections.push({node:d,output:o}),this.drawflow.drawflow[this.module].data[d].inputs[o].connections.push({node:a,input:c}),this.updateConnectionNodes("node-"+a),this.updateConnectionNodes("node-"+d),this.dispatch("connectionCreated",{output_id:a,input_id:d,output_class:c,input_class:o})}else this.dispatch("connectionCancel",!0),this.connection_ele.remove();this.connection_ele=null}else this.dispatch("connectionCancel",!0),this.connection_ele.remove(),this.connection_ele=null}else this.dispatch("connectionCancel",!0),this.connection_ele.remove(),this.connection_ele=null;this.drag=!1,this.drag_point=!1,this.connection=!1,this.ele_selected=null,this.editor_selected=!1,this.dispatch("mouseUp",e)}contextmenu(e){if(this.dispatch("contextmenu",e),e.preventDefault(),"fixed"===this.editor_mode||"view"===this.editor_mode)return!1;if(this.precanvas.getElementsByClassName("drawflow-delete").length&&this.precanvas.getElementsByClassName("drawflow-delete")[0].remove(),this.node_selected||this.connection_selected){var t=document.createElement("div");t.classList.add("drawflow-delete"),t.innerHTML="x",this.node_selected&&this.node_selected.appendChild(t),this.connection_selected&&this.connection_selected.parentElement.classList.length>1&&(t.style.top=e.clientY*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom))-this.precanvas.getBoundingClientRect().y*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom))+"px",t.style.left=e.clientX*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom))-this.precanvas.getBoundingClientRect().x*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom))+"px",this.precanvas.appendChild(t))}}contextmenuDel(){this.precanvas.getElementsByClassName("drawflow-delete").length&&this.precanvas.getElementsByClassName("drawflow-delete")[0].remove()}key(e){if(this.dispatch("keydown",e),"fixed"===this.editor_mode||"view"===this.editor_mode)return!1;("Delete"===e.key||"Backspace"===e.key&&e.metaKey)&&(null!=this.node_selected&&"INPUT"!==this.first_click.tagName&&"TEXTAREA"!==this.first_click.tagName&&!0!==this.first_click.hasAttribute("contenteditable")&&this.removeNodeId(this.node_selected.id),null!=this.connection_selected&&this.removeConnection())}zoom_enter(e,t){e.ctrlKey&&(e.preventDefault(),e.deltaY>0?this.zoom_out():this.zoom_in())}zoom_refresh(){this.dispatch("zoom",this.zoom),this.canvas_x=this.canvas_x/this.zoom_last_value*this.zoom,this.canvas_y=this.canvas_y/this.zoom_last_value*this.zoom,this.zoom_last_value=this.zoom,this.precanvas.style.transform="translate("+this.canvas_x+"px, "+this.canvas_y+"px) scale("+this.zoom+")"}zoom_in(){this.zoomthis.zoom_min&&(this.zoom-=this.zoom_value,this.zoom_refresh())}zoom_reset(){1!=this.zoom&&(this.zoom=1,this.zoom_refresh())}createCurvature(e,t,n,i,s,o){var l=e,c=t,d=n,a=i,r=s;switch(o){case"open":if(e>=n)var h=l+Math.abs(d-l)*r,u=d-Math.abs(d-l)*(-1*r);else h=l+Math.abs(d-l)*r,u=d-Math.abs(d-l)*r;return" M "+l+" "+c+" C "+h+" "+c+" "+u+" "+a+" "+d+" "+a;case"close":if(e>=n)h=l+Math.abs(d-l)*(-1*r),u=d-Math.abs(d-l)*r;else h=l+Math.abs(d-l)*r,u=d-Math.abs(d-l)*r;return" M "+l+" "+c+" C "+h+" "+c+" "+u+" "+a+" "+d+" "+a;case"other":if(e>=n)h=l+Math.abs(d-l)*(-1*r),u=d-Math.abs(d-l)*(-1*r);else h=l+Math.abs(d-l)*r,u=d-Math.abs(d-l)*r;return" M "+l+" "+c+" C "+h+" "+c+" "+u+" "+a+" "+d+" "+a;default:return" M "+l+" "+c+" C "+(h=l+Math.abs(d-l)*r)+" "+c+" "+(u=d-Math.abs(d-l)*r)+" "+a+" "+d+" "+a}}drawConnection(e){var t=document.createElementNS("http://www.w3.org/2000/svg","svg");this.connection_ele=t;var n=document.createElementNS("http://www.w3.org/2000/svg","path");n.classList.add("main-path"),n.setAttributeNS(null,"d",""),t.classList.add("connection"),t.appendChild(n),this.precanvas.appendChild(t);var i=e.parentElement.parentElement.id.slice(5),s=e.classList[1];this.dispatch("connectionStart",{output_id:i,output_class:s})}updateConnection(e,t){const n=this.precanvas,i=this.zoom;let s=n.clientWidth/(n.clientWidth*i);s=s||0;let o=n.clientHeight/(n.clientHeight*i);o=o||0;var l=this.connection_ele.children[0],c=this.ele_selected.offsetWidth/2+(this.ele_selected.getBoundingClientRect().x-n.getBoundingClientRect().x)*s,d=this.ele_selected.offsetHeight/2+(this.ele_selected.getBoundingClientRect().y-n.getBoundingClientRect().y)*o,a=e*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom))-this.precanvas.getBoundingClientRect().x*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom)),r=t*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom))-this.precanvas.getBoundingClientRect().y*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom)),h=this.curvature,u=this.createCurvature(c,d,a,r,h,"openclose");l.setAttributeNS(null,"d",u)}addConnection(e,t,n,i){var s=this.getModuleFromNodeId(e);if(s===this.getModuleFromNodeId(t)){var o=this.getNodeFromId(e),l=!1;for(var c in o.outputs[n].connections){var d=o.outputs[n].connections[c];d.node==t&&d.output==i&&(l=!0)}if(!1===l){if(this.drawflow.drawflow[s].data[e].outputs[n].connections.push({node:t.toString(),output:i}),this.drawflow.drawflow[s].data[t].inputs[i].connections.push({node:e.toString(),input:n}),this.module===s){var a=document.createElementNS("http://www.w3.org/2000/svg","svg"),r=document.createElementNS("http://www.w3.org/2000/svg","path");r.classList.add("main-path"),r.setAttributeNS(null,"d",""),a.classList.add("connection"),a.classList.add("node_in_node-"+t),a.classList.add("node_out_node-"+e),a.classList.add(n),a.classList.add(i),a.appendChild(r),this.precanvas.appendChild(a),this.updateConnectionNodes("node-"+e),this.updateConnectionNodes("node-"+t)}this.dispatch("connectionCreated",{output_id:e,input_id:t,output_class:n,input_class:i})}}}updateConnectionNodes(e){const t="node_in_"+e,n="node_out_"+e;this.line_path;const i=this.container,s=this.precanvas,o=this.curvature,l=this.createCurvature,c=this.reroute_curvature,d=this.reroute_curvature_start_end,a=this.reroute_fix_curvature,r=this.reroute_width,h=this.zoom;let u=s.clientWidth/(s.clientWidth*h);u=u||0;let p=s.clientHeight/(s.clientHeight*h);p=p||0;const f=i.querySelectorAll("."+n);Object.keys(f).map((function(t,n){if(null===f[t].querySelector(".point")){var m=i.querySelector("#"+e),g=f[t].classList[1].replace("node_in_",""),_=i.querySelector("#"+g).querySelectorAll("."+f[t].classList[4])[0],w=_.offsetWidth/2+(_.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,v=_.offsetHeight/2+(_.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,y=m.querySelectorAll("."+f[t].classList[3])[0],C=y.offsetWidth/2+(y.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,x=y.offsetHeight/2+(y.getBoundingClientRect().y-s.getBoundingClientRect().y)*p;const n=l(C,x,w,v,o,"openclose");f[t].children[0].setAttributeNS(null,"d",n)}else{const n=f[t].querySelectorAll(".point");let o="";const m=[];n.forEach((t,a)=>{if(0===a&&n.length-1==0){var f=i.querySelector("#"+e),g=((x=t).getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,_=(x.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,w=(L=f.querySelectorAll("."+t.parentElement.classList[3])[0]).offsetWidth/2+(L.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,v=L.offsetHeight/2+(L.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,y=l(w,v,g,_,d,"open");o+=y,m.push(y);f=t;var C=t.parentElement.classList[1].replace("node_in_",""),x=(E=i.querySelector("#"+C)).querySelectorAll("."+t.parentElement.classList[4])[0];g=(R=E.querySelectorAll("."+t.parentElement.classList[4])[0]).offsetWidth/2+(R.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,_=R.offsetHeight/2+(R.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,w=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,v=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,y=l(w,v,g,_,d,"close");o+=y,m.push(y)}else if(0===a){var L;f=i.querySelector("#"+e),g=((x=t).getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,_=(x.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,w=(L=f.querySelectorAll("."+t.parentElement.classList[3])[0]).offsetWidth/2+(L.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,v=L.offsetHeight/2+(L.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,y=l(w,v,g,_,d,"open");o+=y,m.push(y);f=t,g=((x=n[a+1]).getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,_=(x.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,w=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,v=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,y=l(w,v,g,_,c,"other");o+=y,m.push(y)}else if(a===n.length-1){var E,R;f=t,C=t.parentElement.classList[1].replace("node_in_",""),x=(E=i.querySelector("#"+C)).querySelectorAll("."+t.parentElement.classList[4])[0],g=(R=E.querySelectorAll("."+t.parentElement.classList[4])[0]).offsetWidth/2+(R.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,_=R.offsetHeight/2+(R.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,w=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*(s.clientWidth/(s.clientWidth*h))+r,v=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*(s.clientHeight/(s.clientHeight*h))+r,y=l(w,v,g,_,d,"close");o+=y,m.push(y)}else{f=t,g=((x=n[a+1]).getBoundingClientRect().x-s.getBoundingClientRect().x)*(s.clientWidth/(s.clientWidth*h))+r,_=(x.getBoundingClientRect().y-s.getBoundingClientRect().y)*(s.clientHeight/(s.clientHeight*h))+r,w=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*(s.clientWidth/(s.clientWidth*h))+r,v=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*(s.clientHeight/(s.clientHeight*h))+r,y=l(w,v,g,_,c,"other");o+=y,m.push(y)}}),a?m.forEach((e,n)=>{f[t].children[n].setAttributeNS(null,"d",e)}):f[t].children[0].setAttributeNS(null,"d",o)}}));const m=i.querySelectorAll("."+t);Object.keys(m).map((function(t,n){if(null===m[t].querySelector(".point")){var h=i.querySelector("#"+e),f=m[t].classList[2].replace("node_out_",""),g=i.querySelector("#"+f).querySelectorAll("."+m[t].classList[3])[0],_=g.offsetWidth/2+(g.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,w=g.offsetHeight/2+(g.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,v=(h=h.querySelectorAll("."+m[t].classList[4])[0]).offsetWidth/2+(h.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,y=h.offsetHeight/2+(h.getBoundingClientRect().y-s.getBoundingClientRect().y)*p;const n=l(_,w,v,y,o,"openclose");m[t].children[0].setAttributeNS(null,"d",n)}else{const n=m[t].querySelectorAll(".point");let o="";const h=[];n.forEach((t,a)=>{if(0===a&&n.length-1==0){var f=i.querySelector("#"+e),m=((C=t).getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,g=(C.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,_=(E=f.querySelectorAll("."+t.parentElement.classList[4])[0]).offsetWidth/2+(E.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,w=E.offsetHeight/2+(E.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,v=l(m,g,_,w,d,"close");o+=v,h.push(v);f=t;var y=t.parentElement.classList[2].replace("node_out_",""),C=(L=i.querySelector("#"+y)).querySelectorAll("."+t.parentElement.classList[3])[0];m=(x=L.querySelectorAll("."+t.parentElement.classList[3])[0]).offsetWidth/2+(x.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,g=x.offsetHeight/2+(x.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,_=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,w=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,v=l(m,g,_,w,d,"open");o+=v,h.push(v)}else if(0===a){var x;f=t,y=t.parentElement.classList[2].replace("node_out_",""),C=(L=i.querySelector("#"+y)).querySelectorAll("."+t.parentElement.classList[3])[0],m=(x=L.querySelectorAll("."+t.parentElement.classList[3])[0]).offsetWidth/2+(x.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,g=x.offsetHeight/2+(x.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,_=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,w=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,v=l(m,g,_,w,d,"open");o+=v,h.push(v);f=t,_=((C=n[a+1]).getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,w=(C.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,m=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,g=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,v=l(m,g,_,w,c,"other");o+=v,h.push(v)}else if(a===n.length-1){var L,E;f=t,y=t.parentElement.classList[1].replace("node_in_",""),C=(L=i.querySelector("#"+y)).querySelectorAll("."+t.parentElement.classList[4])[0],_=(E=L.querySelectorAll("."+t.parentElement.classList[4])[0]).offsetWidth/2+(E.getBoundingClientRect().x-s.getBoundingClientRect().x)*u,w=E.offsetHeight/2+(E.getBoundingClientRect().y-s.getBoundingClientRect().y)*p,m=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,g=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,v=l(m,g,_,w,d,"close");o+=v,h.push(v)}else{f=t,_=((C=n[a+1]).getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,w=(C.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,m=(f.getBoundingClientRect().x-s.getBoundingClientRect().x)*u+r,g=(f.getBoundingClientRect().y-s.getBoundingClientRect().y)*p+r,v=l(m,g,_,w,c,"other");o+=v,h.push(v)}}),a?h.forEach((e,n)=>{m[t].children[n].setAttributeNS(null,"d",e)}):m[t].children[0].setAttributeNS(null,"d",o)}}))}dblclick(e){null!=this.connection_selected&&this.reroute&&this.createReroutePoint(this.connection_selected),"point"===e.target.classList[0]&&this.removeReroutePoint(e.target)}createReroutePoint(e){this.connection_selected.classList.remove("selected");const t=this.connection_selected.parentElement.classList[2].slice(9),n=this.connection_selected.parentElement.classList[1].slice(13),i=this.connection_selected.parentElement.classList[3],s=this.connection_selected.parentElement.classList[4];this.connection_selected=null;const o=document.createElementNS("http://www.w3.org/2000/svg","circle");o.classList.add("point");var l=this.pos_x*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom))-this.precanvas.getBoundingClientRect().x*(this.precanvas.clientWidth/(this.precanvas.clientWidth*this.zoom)),c=this.pos_y*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom))-this.precanvas.getBoundingClientRect().y*(this.precanvas.clientHeight/(this.precanvas.clientHeight*this.zoom));o.setAttributeNS(null,"cx",l),o.setAttributeNS(null,"cy",c),o.setAttributeNS(null,"r",this.reroute_width);let d=0;if(this.reroute_fix_curvature){const t=e.parentElement.querySelectorAll(".main-path").length;var a=document.createElementNS("http://www.w3.org/2000/svg","path");if(a.classList.add("main-path"),a.setAttributeNS(null,"d",""),e.parentElement.insertBefore(a,e.parentElement.children[t]),1===t)e.parentElement.appendChild(o);else{const n=Array.from(e.parentElement.children).indexOf(e);d=n,e.parentElement.insertBefore(o,e.parentElement.children[n+t+1])}}else e.parentElement.appendChild(o);const r=t.slice(5),h=this.drawflow.drawflow[this.module].data[r].outputs[i].connections.findIndex((function(e,t){return e.node===n&&e.output===s}));void 0===this.drawflow.drawflow[this.module].data[r].outputs[i].connections[h].points&&(this.drawflow.drawflow[this.module].data[r].outputs[i].connections[h].points=[]),this.reroute_fix_curvature?(d>0||this.drawflow.drawflow[this.module].data[r].outputs[i].connections[h].points!==[]?this.drawflow.drawflow[this.module].data[r].outputs[i].connections[h].points.splice(d,0,{pos_x:l,pos_y:c}):this.drawflow.drawflow[this.module].data[r].outputs[i].connections[h].points.push({pos_x:l,pos_y:c}),e.parentElement.querySelectorAll(".main-path").forEach((e,t)=>{e.classList.remove("selected")})):this.drawflow.drawflow[this.module].data[r].outputs[i].connections[h].points.push({pos_x:l,pos_y:c}),this.dispatch("addReroute",r),this.updateConnectionNodes(t)}removeReroutePoint(e){const t=e.parentElement.classList[2].slice(9),n=e.parentElement.classList[1].slice(13),i=e.parentElement.classList[3],s=e.parentElement.classList[4];let o=Array.from(e.parentElement.children).indexOf(e);const l=t.slice(5),c=this.drawflow.drawflow[this.module].data[l].outputs[i].connections.findIndex((function(e,t){return e.node===n&&e.output===s}));if(this.reroute_fix_curvature){const t=e.parentElement.querySelectorAll(".main-path").length;e.parentElement.children[t-1].remove(),o-=t,o<0&&(o=0)}else o--;this.drawflow.drawflow[this.module].data[l].outputs[i].connections[c].points.splice(o,1),e.remove(),this.dispatch("removeReroute",l),this.updateConnectionNodes(t)}registerNode(e,t,n=null,i=null){this.noderegister[e]={html:t,props:n,options:i}}getNodeFromId(e){var t=this.getModuleFromNodeId(e);return JSON.parse(JSON.stringify(this.drawflow.drawflow[t].data[e]))}getNodesFromName(e){var t=[];const n=this.drawflow.drawflow;return Object.keys(n).map((function(i,s){for(var o in n[i].data)n[i].data[o].name==e&&t.push(n[i].data[o].id)})),t}addNode(e,t,n,i,s,o,l,c,d=!1){if(this.useuuid)var a=this.getUuid();else a=this.nodeId;const r=document.createElement("div");r.classList.add("parent-node");const h=document.createElement("div");h.innerHTML="",h.setAttribute("id","node-"+a),h.classList.add("drawflow-node"),""!=o&&h.classList.add(...o.split(" "));const u=document.createElement("div");u.classList.add("inputs");const p=document.createElement("div");p.classList.add("outputs");const f={};for(var m=0;me(this.noderegister[c].html,{props:this.noderegister[c].props}),...this.noderegister[c].options}).$mount();_.appendChild(e.$el)}Object.entries(l).forEach((function(e,t){if("object"==typeof e[1])!function e(t,n,i){if(null===t)t=l[n];else t=t[n];null!==t&&Object.entries(t).forEach((function(n,s){if("object"==typeof n[1])e(t,n[0],i+"-"+n[0]);else for(var o=_.querySelectorAll("[df-"+i+"-"+n[0]+"]"),l=0;lt(this.noderegister[e.html].html,{props:this.noderegister[e.html].props}),...this.noderegister[e.html].options}).$mount();c.appendChild(t.$el)}Object.entries(e.data).forEach((function(t,n){if("object"==typeof t[1])!function t(n,i,s){if(null===n)n=e.data[i];else n=n[i];null!==n&&Object.entries(n).forEach((function(e,i){if("object"==typeof e[1])t(n,e[0],s+"-"+e[0]);else for(var o=c.querySelectorAll("[df-"+s+"-"+e[0]+"]"),l=0;l{const a=e.outputs[s].connections[o].node,r=e.outputs[s].connections[o].output,h=i.querySelector(".connection.node_in_node-"+a+".node_out_node-"+e.id+"."+s+"."+r);if(n&&0===d)for(var u=0;u{this.removeSingleConnection(e.id_output,e.id,e.output_class,e.input_class)}),delete this.drawflow.drawflow[n].data[e].inputs[t];const o=[],l=this.drawflow.drawflow[n].data[e].inputs;Object.keys(l).map((function(e,t){o.push(l[e])})),this.drawflow.drawflow[n].data[e].inputs={};const c=t.slice(6);let d=[];if(o.forEach((t,i)=>{t.connections.forEach((e,t)=>{d.push(e)}),this.drawflow.drawflow[n].data[e].inputs["input_"+(i+1)]=t}),d=new Set(d.map(e=>JSON.stringify(e))),d=Array.from(d).map(e=>JSON.parse(e)),this.module===n){this.container.querySelectorAll("#node-"+e+" .inputs .input").forEach((e,t)=>{const n=e.classList[1].slice(6);parseInt(c){this.drawflow.drawflow[n].data[t.node].outputs[t.input].connections.forEach((i,s)=>{if(i.node==e){const o=i.output.slice(6);if(parseInt(c){this.removeSingleConnection(e.id,e.id_input,e.output_class,e.input_class)}),delete this.drawflow.drawflow[n].data[e].outputs[t];const o=[],l=this.drawflow.drawflow[n].data[e].outputs;Object.keys(l).map((function(e,t){o.push(l[e])})),this.drawflow.drawflow[n].data[e].outputs={};const c=t.slice(7);let d=[];if(o.forEach((t,i)=>{t.connections.forEach((e,t)=>{d.push({node:e.node,output:e.output})}),this.drawflow.drawflow[n].data[e].outputs["output_"+(i+1)]=t}),d=new Set(d.map(e=>JSON.stringify(e))),d=Array.from(d).map(e=>JSON.parse(e)),this.module===n){this.container.querySelectorAll("#node-"+e+" .outputs .output").forEach((e,t)=>{const n=e.classList[1].slice(7);parseInt(c){this.drawflow.drawflow[n].data[t.node].inputs[t.output].connections.forEach((i,s)=>{if(i.node==e){const o=i.input.slice(7);if(parseInt(c)-1){this.module===s&&this.container.querySelector(".connection.node_in_node-"+t+".node_out_node-"+e+"."+n+"."+i).remove();var o=this.drawflow.drawflow[s].data[e].outputs[n].connections.findIndex((function(e,n){return e.node==t&&e.output===i}));this.drawflow.drawflow[s].data[e].outputs[n].connections.splice(o,1);var l=this.drawflow.drawflow[s].data[t].inputs[i].connections.findIndex((function(t,i){return t.node==e&&t.input===n}));return this.drawflow.drawflow[s].data[t].inputs[i].connections.splice(l,1),this.dispatch("connectionRemoved",{output_id:e,input_id:t,output_class:n,input_class:i}),!0}return!1}return!1}removeConnectionNodeId(e){const t="node_in_"+e,n="node_out_"+e,i=this.container.querySelectorAll("."+n);for(var s=i.length-1;s>=0;s--){var o=i[s].classList,l=this.drawflow.drawflow[this.module].data[o[1].slice(13)].inputs[o[4]].connections.findIndex((function(e,t){return e.node===o[2].slice(14)&&e.input===o[3]}));this.drawflow.drawflow[this.module].data[o[1].slice(13)].inputs[o[4]].connections.splice(l,1);var c=this.drawflow.drawflow[this.module].data[o[2].slice(14)].outputs[o[3]].connections.findIndex((function(e,t){return e.node===o[1].slice(13)&&e.output===o[4]}));this.drawflow.drawflow[this.module].data[o[2].slice(14)].outputs[o[3]].connections.splice(c,1),i[s].remove(),this.dispatch("connectionRemoved",{output_id:o[2].slice(14),input_id:o[1].slice(13),output_class:o[3],input_class:o[4]})}const d=this.container.querySelectorAll("."+t);for(s=d.length-1;s>=0;s--){o=d[s].classList,c=this.drawflow.drawflow[this.module].data[o[2].slice(14)].outputs[o[3]].connections.findIndex((function(e,t){return e.node===o[1].slice(13)&&e.output===o[4]}));this.drawflow.drawflow[this.module].data[o[2].slice(14)].outputs[o[3]].connections.splice(c,1);l=this.drawflow.drawflow[this.module].data[o[1].slice(13)].inputs[o[4]].connections.findIndex((function(e,t){return e.node===o[2].slice(14)&&e.input===o[3]}));this.drawflow.drawflow[this.module].data[o[1].slice(13)].inputs[o[4]].connections.splice(l,1),d[s].remove(),this.dispatch("connectionRemoved",{output_id:o[2].slice(14),input_id:o[1].slice(13),output_class:o[3],input_class:o[4]})}}getModuleFromNodeId(e){var t;const n=this.drawflow.drawflow;return Object.keys(n).map((function(i,s){Object.keys(n[i].data).map((function(n,s){n==e&&(t=i)}))})),t}addModule(e){this.drawflow.drawflow[e]={data:{}},this.dispatch("moduleCreated",e)}changeModule(e){this.dispatch("moduleChanged",e),this.module=e,this.precanvas.innerHTML="",this.canvas_x=0,this.canvas_y=0,this.pos_x=0,this.pos_y=0,this.mouse_x=0,this.mouse_y=0,this.zoom=1,this.zoom_last_value=1,this.precanvas.style.transform="",this.import(this.drawflow,!1)}removeModule(e){this.module===e&&this.changeModule("Home"),delete this.drawflow.drawflow[e],this.dispatch("moduleRemoved",e)}clearModuleSelected(){this.precanvas.innerHTML="",this.drawflow.drawflow[this.module]={data:{}}}clear(){this.precanvas.innerHTML="",this.drawflow={drawflow:{Home:{data:{}}}}}export(){const e=JSON.parse(JSON.stringify(this.drawflow));return this.dispatch("export",e),e}import(e,t=!0){this.clear(),this.drawflow=JSON.parse(JSON.stringify(e)),this.load(),t&&this.dispatch("import","import")}on(e,t){return"function"!=typeof t?(console.error("The listener callback must be a function, the given type is "+typeof t),!1):"string"!=typeof e?(console.error("The event name must be a string, the given type is "+typeof e),!1):(void 0===this.events[e]&&(this.events[e]={listeners:[]}),void this.events[e].listeners.push(t))}removeListener(e,t){if(!this.events[e])return!1;const n=this.events[e].listeners,i=n.indexOf(t);i>-1&&n.splice(i,1)}dispatch(e,t){if(void 0===this.events[e])return!1;this.events[e].listeners.forEach(e=>{e(t)})}getUuid(){for(var e=[],t=0;t<36;t++)e[t]="0123456789abcdef".substr(Math.floor(16*Math.random()),1);return e[14]="4",e[19]="0123456789abcdef".substr(3&e[19]|8,1),e[8]=e[13]=e[18]=e[23]="-",e.join("")}}}]).default})); \ No newline at end of file diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx new file mode 100644 index 000000000..f46907389 --- /dev/null +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; + +function TestIcon() { + return null; +} + +describe("buildDraftPanelDescriptor", () => { + it("uses the initial prompt title and running loader bucket during create", () => { + const descriptor = buildDraftPanelDescriptor({ + isCreating: true, + pendingPrompt: "Build the dashboard", + icon: TestIcon, + }); + + expect(descriptor).toMatchObject({ + label: "Build the dashboard", + subtitle: "Creating chat", + titleState: "ready", + statusBucket: "running", + }); + }); + + it("falls back to the draft title for empty create prompts", () => { + const descriptor = buildDraftPanelDescriptor({ + isCreating: true, + pendingPrompt: " ", + icon: TestIcon, + }); + + expect(descriptor.label).toBe("New Chat"); + }); + + it("keeps ordinary draft tabs labeled as new agents", () => { + const descriptor = buildDraftPanelDescriptor({ isCreating: false, icon: TestIcon }); + + expect(descriptor).toMatchObject({ + label: "New Chat", + subtitle: "New Chat", + titleState: "ready", + statusBucket: null, + }); + }); + + it("uses the active language for draft descriptor chrome", async () => { + await i18n.changeLanguage("zh-CN"); + const idleDescriptor = buildDraftPanelDescriptor({ + isCreating: false, + icon: TestIcon, + }); + const creatingDescriptor = buildDraftPanelDescriptor({ + isCreating: true, + pendingPrompt: " ", + icon: TestIcon, + }); + + // "对话" (chat), not "Agent": the panel names the conversation surface, and the glossary + // reserves "agent" for the AI session behind it. See docs/glossary.md. + expect(idleDescriptor).toMatchObject({ + label: "新建对话", + subtitle: "新建对话", + }); + expect(creatingDescriptor).toMatchObject({ + label: "新建对话", + subtitle: "正在创建对话", + }); + await i18n.changeLanguage("en"); + }); +}); diff --git a/packages/app/src/panels/agent-panel-load-state.test.ts b/packages/app/src/panels/agent-panel-load-state.test.ts new file mode 100644 index 000000000..3bab4818a --- /dev/null +++ b/packages/app/src/panels/agent-panel-load-state.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { AgentScreenMissingState } from "@/hooks/use-agent-screen-state-machine"; +import { + clearHistorySyncErrorAfterSuccessfulSync, + reconcileMissingAgentStateWithPresentAgent, +} from "./agent-panel-load-state"; + +describe("reconcileMissingAgentStateWithPresentAgent", () => { + it("clears lookup-only states once the agent record is present", () => { + expect(reconcileMissingAgentStateWithPresentAgent({ kind: "resolving" })).toEqual({ + kind: "idle", + }); + expect( + reconcileMissingAgentStateWithPresentAgent({ + kind: "not_found", + message: "Agent not found: agent-1", + }), + ).toEqual({ kind: "idle" }); + }); + + it("preserves history sync errors while the agent record is present", () => { + const state: AgentScreenMissingState = { + kind: "error", + message: "Failed to get logs: session is archived", + }; + + expect(reconcileMissingAgentStateWithPresentAgent(state)).toBe(state); + }); +}); + +describe("clearHistorySyncErrorAfterSuccessfulSync", () => { + it("clears a sync error after a later successful refresh", () => { + expect( + clearHistorySyncErrorAfterSuccessfulSync({ + kind: "error", + message: "Failed to get logs: session is archived", + }), + ).toEqual({ kind: "idle" }); + }); + + it("leaves non-error states alone", () => { + const state: AgentScreenMissingState = { kind: "resolving" }; + + expect(clearHistorySyncErrorAfterSuccessfulSync(state)).toBe(state); + }); +}); diff --git a/packages/app/src/panels/agent-panel-load-state.ts b/packages/app/src/panels/agent-panel-load-state.ts new file mode 100644 index 000000000..9a77f0492 --- /dev/null +++ b/packages/app/src/panels/agent-panel-load-state.ts @@ -0,0 +1,19 @@ +import type { AgentScreenMissingState } from "@/hooks/use-agent-screen-state-machine"; + +export function reconcileMissingAgentStateWithPresentAgent( + state: AgentScreenMissingState, +): AgentScreenMissingState { + if (state.kind === "resolving" || state.kind === "not_found") { + return { kind: "idle" }; + } + return state; +} + +export function clearHistorySyncErrorAfterSuccessfulSync( + state: AgentScreenMissingState, +): AgentScreenMissingState { + if (state.kind === "error") { + return { kind: "idle" }; + } + return state; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx new file mode 100644 index 000000000..c0bbecea1 --- /dev/null +++ b/packages/app/src/panels/agent-panel.tsx @@ -0,0 +1,1988 @@ +import type { DaemonClient } from "@otto-code/client/internal/daemon-client"; +import { isExternalPreviewServerId } from "@otto-code/protocol/messages"; +import type { TFunction } from "i18next"; +import { SquarePen } from "@/components/icons/material-icons"; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import ReanimatedAnimated from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { shallow, useShallow } from "zustand/shallow"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; +import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; +import { ObservedSubagentCallout } from "@/components/observed-subagent-callout"; +import { BlackChatScope } from "@/components/black-chat-scope"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { Composer } from "@/composer"; +import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; +import { getProviderIcon } from "@/components/provider-icons"; +import { + ToastViewport, + useToastHost, + type ToastApi, + type ToastState, +} from "@/components/toast-host"; +import type { WorkspaceComposerAttachment } from "@/attachments/types"; +import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { isNative, isWeb } from "@/constants/platform"; +import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; +import { useAgentInitialization } from "@/hooks/use-agent-initialization"; +import { shouldSyncAgentTimelineOnFocus } from "@/timeline/timeline-sync-plan"; +import { useAgentStreamRetention } from "@/timeline/use-agent-stream-retention"; +import { useAppSettings } from "@/hooks/use-settings"; +import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; +import { + type AgentScreenAgent, + type AgentScreenContinuity, + type AgentScreenMissingState, + type AgentScreenViewState, + useAgentScreenStateMachine, +} from "@/hooks/use-agent-screen-state-machine"; +import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useContainerWidthBelow } from "@/hooks/use-container-width"; +import { useContainerHeight } from "@/hooks/use-container-height"; +import { + clearHistorySyncErrorAfterSuccessfulSync, + reconcileMissingAgentStateWithPresentAgent, +} from "@/panels/agent-panel-load-state"; +import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { RenderProfile } from "@/utils/render-profiler"; +import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; +import { + type HostRuntimeConnectionStatus, + useHostRuntimeClient, + useHostRuntimeConnectionStatus, + useHostRuntimeIsConnected, + useHostRuntimeLastError, + useHosts, +} from "@/runtime/host-runtime"; +import { + deriveRouteBottomAnchorIntent, + deriveRouteBottomAnchorRequest, +} from "@/screens/agent/agent-ready-screen-bottom-anchor"; +import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; +import { useBrowserStore } from "@/stores/browser-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; +import { usePanelStore } from "@/stores/panel-store"; +import { usePreviewRunningServersStore } from "@/stores/preview-running-servers-store"; +import { type Agent, useSessionStore } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import type { Theme } from "@/styles/theme"; +import { + useArchiveSubagent, + useAutoClearCompletedSubagents, + useClearCompletedSubagents, + useClearedSubagentTokens, + useDetachSubagent, + useStopSubagent, + useSubagentsForParent, +} from "@/subagents"; +import { useAutoClearCompletedSubagentsSetting } from "@/hooks/use-auto-clear-completed-subagents"; +import { SubagentsTrack } from "@/subagents/track"; +import { ChatMetricsBar } from "@/subagents/chat-metrics-bar"; +import { + useAutoClearCompletedBackgroundTasks, + useBackgroundShellTasksForParent, + useClearCompletedBackgroundTasks, + useStopBackgroundTask, +} from "@/background-tasks"; +import { + useAutoClearCompletedBackgroundTasksSetting, + useAutoClearFailedBackgroundTasksSetting, +} from "@/hooks/use-auto-clear-completed-background-tasks"; +import { BackgroundTasksTrack } from "@/background-tasks/track"; +import { RateLimitWarningTrack } from "@/composer/rate-limit-warning-track"; +import { ContextHealthTrack } from "@/composer/context-health-track"; +import { COMPOSER_TRACK_LAYERS } from "@/composer/track-transition"; +import { + SuggestedTasksOverlay, + useSuggestedTaskActions, + useSuggestedTasksForParent, +} from "@/suggested-tasks"; +import { PinnedTaskListOverlay, usePinnedTaskList } from "@/pinned-task-list"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem } from "@/types/stream"; +import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; +import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; + +interface ChatAgentStateShape { + serverId: string | null; + id: string | null; + provider?: Agent["provider"]; + status: Agent["status"] | null; + cwd: string | null; + workspaceId?: string; + capabilities?: Agent["capabilities"]; + currentModeId?: Agent["currentModeId"]; + model?: Agent["model"]; + thinkingOptionId?: Agent["thinkingOptionId"]; + runtimeInfo?: Agent["runtimeInfo"]; + features?: Agent["features"]; + lastError?: Agent["lastError"] | null; + personalitySpinner?: Agent["personalitySpinner"]; +} + +interface ChatAgentSelectedState extends ChatAgentStateShape { + archivedAt: Date | null; + requiresAttention: boolean; + attentionReason: Agent["attentionReason"] | null; +} + +function resolveChatAgentFromSession( + state: ReturnType, + serverId: string, + agentId: string | undefined, +): Agent | null { + if (!agentId) return null; + const session = state.sessions[serverId]; + return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null; +} + +const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = { + serverId: null, + id: null, + status: null, + cwd: null, + lastError: null, + archivedAt: null, + requiresAttention: false, + attentionReason: null, +}; + +export function selectChatAgentState( + state: ReturnType, + serverId: string, + agentId: string | undefined, +): ChatAgentSelectedState { + const agent = resolveChatAgentFromSession(state, serverId, agentId); + if (!agent) return EMPTY_CHAT_AGENT_STATE; + return { + serverId: agent.serverId, + id: agent.id, + provider: agent.provider, + status: agent.status, + cwd: agent.cwd, + workspaceId: agent.workspaceId, + capabilities: agent.capabilities, + currentModeId: agent.currentModeId, + model: agent.model, + thinkingOptionId: agent.thinkingOptionId, + runtimeInfo: agent.runtimeInfo, + features: agent.features, + lastError: agent.lastError ?? null, + personalitySpinner: agent.personalitySpinner ?? null, + archivedAt: agent.archivedAt ?? null, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + }; +} + +export function buildChatAgentFromState( + state: ChatAgentStateShape, + projectPlacement: Agent["projectPlacement"] | null, +): AgentScreenAgent | null { + if (!state.serverId || !state.id || !state.status || !state.cwd) { + return null; + } + return { + serverId: state.serverId, + id: state.id, + provider: state.provider, + status: state.status, + cwd: state.cwd, + workspaceId: state.workspaceId, + capabilities: state.capabilities, + currentModeId: state.currentModeId, + model: state.model, + thinkingOptionId: state.thinkingOptionId, + runtimeInfo: state.runtimeInfo, + features: state.features, + lastError: state.lastError ?? null, + personalitySpinner: state.personalitySpinner ?? null, + projectPlacement, + }; +} + +function renderChatAgentNonReadyView(args: { + viewState: AgentScreenViewState; + effectiveAgent: AgentScreenAgent | null; + t: TFunction; +}): React.ReactElement | null { + const { viewState, effectiveAgent, t } = args; + if (viewState.tag === "not_found") { + return ( + + + {t("agentPanel.states.notFound")} + + + ); + } + if (viewState.tag === "error") { + return ( + + + {t("agentPanel.states.failedToLoad")} + {viewState.message} + + + ); + } + if (viewState.tag === "boot" || !effectiveAgent) { + return ( + + + + + + ); + } + return null; +} + +function formatProviderLabel(provider: Agent["provider"]): string { + if (!provider) { + return "Agent"; + } + return provider + .split(/[-_\s]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent" || normalized.toLowerCase() === "new chat") { + return null; + } + return normalized; +} + +function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean { + return !agent.archivedAt && Boolean(agent.projectPlacement); +} + +type FetchAgentResult = Awaited>; + +export function storeFetchedAgentDetail(input: { + serverId: string; + result: NonNullable; +}): Agent { + const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId); + const hydrated: Agent = applyLegacyDaemonWorkspaceOwnership({ + serverId: input.serverId, + agent: { + ...normalized, + projectPlacement: input.result.project, + }, + }); + const store = useSessionStore.getState(); + + if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { + store.setAgents(input.serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + } else { + store.setAgentDetails(input.serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + } + + store.setPendingPermissions(input.serverId, (previous) => { + const next = new Map(previous); + for (const [key, pending] of next.entries()) { + if (pending.agentId === hydrated.id) { + next.delete(key); + } + } + for (const request of hydrated.pendingPermissions) { + const key = derivePendingPermissionKey(hydrated.id, request); + next.set(key, { key, agentId: hydrated.id, request }); + } + return next; + }); + + return hydrated; +} + +function buildAgentDescriptorState(agent: Agent | null) { + return { + // No fallback provider: an unhydrated agent must not borrow another + // provider's logo. Empty resolves to the neutral Bot icon instead. + provider: agent?.provider ?? "", + title: agent?.title ?? null, + status: agent?.status ?? null, + pendingPermissionCount: agent?.pendingPermissions.length ?? 0, + requiresAttention: agent?.requiresAttention ?? false, + attentionReason: agent?.attentionReason ?? null, + personalitySpinner: agent?.personalitySpinner ?? null, + }; +} + +function useAgentPanelDescriptor( + target: { kind: "agent"; agentId: string }, + context: { serverId: string }, +): PanelDescriptor { + const descriptorState = useSessionStore( + useShallow((state) => { + const session = state.sessions[context.serverId]; + const agent = + session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; + return buildAgentDescriptorState(agent); + }), + ); + const provider = descriptorState.provider; + const label = resolveWorkspaceAgentTabLabel(descriptorState.title); + const icon = getProviderIcon(provider); + + return { + label: label ?? "", + subtitle: provider ? `${formatProviderLabel(provider)} agent` : "Agent", + titleState: label ? "ready" : "loading", + icon, + statusBucket: descriptorState.status + ? deriveSidebarStateBucket({ + status: descriptorState.status, + pendingPermissionCount: descriptorState.pendingPermissionCount, + requiresAttention: descriptorState.requiresAttention, + attentionReason: descriptorState.attentionReason, + }) + : null, + personalitySpinner: descriptorState.personalitySpinner, + provider, + }; +} + +function AgentPanel() { + const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { isInteractive } = usePaneFocus(); + invariant(target.kind === "agent", "AgentPanel requires agent target"); + + return ( + + ); +} + +function DraftPanel() { + const { + serverId, + workspaceId, + tabId, + target, + openFileInWorkspace, + openImportSheet, + retargetCurrentTab, + } = usePaneContext(); + const { isInteractive } = usePaneFocus(); + invariant(target.kind === "draft", "DraftPanel requires draft target"); + + const handleCreated = useCallback( + (agentSnapshot: Parameters[0]) => { + const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); + useSessionStore.getState().setAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentSnapshot.id, agent); + return next; + }); + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + }, + [retargetCurrentTab, serverId], + ); + + return ( + + ); +} + +export function AgentConversationPanel() { + const { target } = usePaneContext(); + const { settings } = useAppSettings(); + invariant( + target.kind === "draft" || target.kind === "agent", + "AgentConversationPanel requires an agent or draft target", + ); + const content = target.kind === "draft" ? : ; + // Black tab background: render the whole chat pane (stream + composer) on + // pure black with dark-theme colors regardless of the app-wide light/dark + // mode. Chat tabs only — terminal/browser/preview panes are not wrapped. + return {content}; +} + +export const agentPanelRegistration: PanelRegistration<"agent"> = { + kind: "agent", + component: AgentConversationPanel, + useDescriptor: useAgentPanelDescriptor, +}; + +export function useDraftPanelDescriptor( + target: { kind: "draft"; draftId: string }, + context: { serverId: string }, +) { + const createDescriptorState = useCreateFlowStore( + useShallow((state) => { + const pending = state.pendingByDraftId[target.draftId]; + if (pending?.serverId !== context.serverId || pending.lifecycle !== "active") { + return { + isCreating: false, + pendingPrompt: null, + }; + } + return { + isCreating: true, + pendingPrompt: pending.text, + }; + }), + ); + + return buildDraftPanelDescriptor({ + ...createDescriptorState, + icon: SquarePen, + }); +} + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const EMPTY_PENDING_PERMISSIONS = new Map(); +const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; + +type RouteBottomAnchorRequest = ReturnType; + +function findActiveCreateHandoff(input: { + pendingByDraftId: ReturnType["pendingByDraftId"]; + serverId: string; + agentId?: string; +}): boolean { + if (!input.agentId) { + return false; + } + return Object.values(input.pendingByDraftId).some( + (pending) => + pending.lifecycle === "sent" && + pending.serverId === input.serverId && + pending.agentId === input.agentId, + ); +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function isNotFoundErrorMessage(message: string): boolean { + return /agent not found|not found/i.test(message); +} + +type AgentLookupState = + | { tag: "idle" } + | { tag: "loading" } + | { tag: "not_found"; message: string } + | { tag: "error"; message: string }; + +function AgentPanelContent({ + serverId, + agentId, + isPaneFocused, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId: string; + isPaneFocused: boolean; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; +}) { + const { t } = useTranslation(); + const resolvedAgentId = agentId.trim() || undefined; + const resolvedServerId = serverId.trim() || undefined; + const daemons = useHosts(); + const runtimeServerId = resolvedServerId ?? ""; + const runtimeClient = useHostRuntimeClient(runtimeServerId); + const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId); + const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId); + const runtimeLastError = useHostRuntimeLastError(runtimeServerId); + + const connectionServerId = resolvedServerId ?? null; + const daemon = connectionServerId + ? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null) + : null; + const serverLabel = + daemon?.label ?? connectionServerId ?? t("agentPanel.unavailable.selectedHost"); + const isUnknownDaemon = Boolean(connectionServerId && !daemon); + const connectionStatus: HostRuntimeConnectionStatus = + isUnknownDaemon && runtimeConnectionStatus === "connecting" + ? "offline" + : runtimeConnectionStatus; + const lastConnectionError = runtimeLastError; + + if (!resolvedServerId || !runtimeClient) { + return ( + + ); + } + + return ( + + ); +} + +function AgentPanelBody({ + serverId, + agentId, + isPaneFocused, + client, + isConnected, + connectionStatus, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId?: string; + isPaneFocused: boolean; + client: NonNullable>; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; +}) { + const { t } = useTranslation(); + const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const projectPlacement = useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!agentId) { + return null; + } + const session = state.sessions[serverId]; + return ( + session?.agents?.get(agentId)?.projectPlacement ?? + session?.agentDetails?.get(agentId)?.projectPlacement ?? + null + ); + }, + (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), + ); + const agentState = useSessionStore( + useShallow((state) => selectChatAgentState(state, serverId, agentId)), + ); + const [lookupState, setLookupState] = useState({ tag: "idle" }); + const lookupAttemptTokenRef = useRef(0); + + useEffect(() => { + lookupAttemptTokenRef.current += 1; + setLookupState({ tag: "idle" }); + }, [agentId, serverId]); + + // A track row can outlive its record in the store (observed subagents are + // ephemeral projections; a placement remove or reconnect drops them). The + // fetch now resolves those from the daemon registry, so a not_found is + // recoverable — let the user re-run the lookup instead of dead-ending. + const handleRetryLookup = useCallback(() => { + lookupAttemptTokenRef.current += 1; + setLookupState({ tag: "idle" }); + }, []); + + useEffect(() => { + if (!agentId) { + return; + } + if (agentState.id) { + if (lookupState.tag !== "idle") { + setLookupState({ tag: "idle" }); + } + return; + } + if (!isConnected || !hasSession) { + return; + } + if (lookupState.tag === "loading" || lookupState.tag === "not_found") { + return; + } + + setLookupState({ tag: "loading" }); + const attemptToken = ++lookupAttemptTokenRef.current; + + client + .fetchAgent({ agentId }) + .then((result) => { + if (attemptToken !== lookupAttemptTokenRef.current) { + return; + } + if (!result) { + setLookupState({ + tag: "not_found", + message: `Agent not found: ${agentId}`, + }); + return; + } + + storeFetchedAgentDetail({ serverId, result }); + setLookupState({ tag: "idle" }); + return; + }) + .catch((error) => { + if (attemptToken !== lookupAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setLookupState({ tag: "not_found", message }); + return; + } + setLookupState({ tag: "error", message }); + }); + }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); + + if (lookupState.tag === "not_found") { + return ( + + + {t("agentPanel.states.notFound")} + + {t("common.actions.retry")} + + + + ); + } + + if (lookupState.tag === "error") { + return ( + + + {t("agentPanel.states.failedToLoad")} + {lookupState.message} + + + ); + } + + const agent: AgentScreenAgent | null = + agentState.serverId && agentState.id && agentState.status && agentState.cwd + ? { + serverId: agentState.serverId, + id: agentState.id, + provider: agentState.provider, + status: agentState.status, + cwd: agentState.cwd, + workspaceId: agentState.workspaceId, + capabilities: agentState.capabilities, + currentModeId: agentState.currentModeId, + model: agentState.model, + thinkingOptionId: agentState.thinkingOptionId, + runtimeInfo: agentState.runtimeInfo, + features: agentState.features, + lastError: agentState.lastError ?? null, + personalitySpinner: agentState.personalitySpinner ?? null, + projectPlacement, + } + : null; + + if (!agent) { + return ( + + + + + + ); + } + + return ( + + ); +} + +function ChatAgentContent({ + serverId, + agentId, + isPaneFocused, + client, + isConnected, + connectionStatus, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId?: string; + isPaneFocused: boolean; + client: NonNullable>; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; +}) { + const { t } = useTranslation(); + const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); + const { isArchivingAgent } = useArchiveAgent(); + const streamViewRef = useRef(null); + const clearOnAgentBlurRef = useRef<() => void>(() => {}); + const wasPaneFocusedRef = useRef(isPaneFocused); + const reconnectToastArmedRef = useRef(false); + const initAttemptTokenRef = useRef(0); + const routeBottomAnchorRequestRef = useRef<{ + routeKey: string; + reason: "initial-entry" | "resume"; + } | null>(null); + const agentState = useSessionStore( + useShallow((state) => selectChatAgentState(state, serverId, agentId)), + ); + const projectPlacement = useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!agentId) { + return null; + } + const session = state.sessions[serverId]; + return ( + session?.agents?.get(agentId)?.projectPlacement ?? + session?.agentDetails?.get(agentId)?.projectPlacement ?? + null + ); + }, + (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), + ); + const isInitializingFromMap = useSessionStore((state) => + agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false, + ); + const historySyncGeneration = useSessionStore( + (state) => state.sessions[serverId]?.historySyncGeneration ?? 0, + ); + const hasAppliedAuthoritativeHistory = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true + : false, + ); + const agentHistorySyncGeneration = useSessionStore((state) => + agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, + ); + const hasActiveCreateHandoff = useCreateFlowStore((state) => + findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }), + ); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const { ensureAgentIsInitialized } = useAgentInitialization({ + serverId, + client: hasSession ? client : null, + }); + const [missingAgentState, setMissingAgentState] = useState({ + kind: "idle", + }); + + const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; + + const attentionController = useAgentAttentionClear({ + agentId, + client, + isConnected, + requiresAttention: agentState.requiresAttention, + attentionReason: agentState.attentionReason, + isScreenFocused: isPaneFocused, + }); + useEffect(() => { + clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; + }, [attentionController.clearOnAgentBlur]); + + const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ + mode: "translate", + }); + + const handleHistorySyncFailure = useCallback( + ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { + if (agentId) { + console.warn("[AgentPanel] history sync failed", { + origin, + agentId, + error, + }); + } + const message = toErrorMessage(error); + setMissingAgentState((previous) => { + if (previous.kind === "error" && previous.message === message) { + return previous; + } + return { kind: "error", message }; + }); + }, + [agentId], + ); + + const ensureInitializedWithSyncErrorHandling = useCallback( + (origin: "focus" | "entry") => { + if (!agentId) { + return; + } + ensureAgentIsInitialized(agentId) + .then(() => { + setMissingAgentState(clearHistorySyncErrorAfterSuccessfulSync); + return undefined; + }) + .catch((error) => { + handleHistorySyncFailure({ origin, error }); + return undefined; + }); + }, + [agentId, ensureAgentIsInitialized, handleHistorySyncFailure], + ); + + useEffect(() => { + if (connectionStatus === "online") { + if (reconnectToastArmedRef.current) { + reconnectToastArmedRef.current = false; + dismissToast(); + } + return; + } + if (connectionStatus === "idle") { + return; + } + if (!reconnectToastArmedRef.current) { + reconnectToastArmedRef.current = true; + toastApi.show(t("agentPanel.states.reconnecting"), { + durationMs: null, + testID: "agent-reconnecting-toast", + }); + } + }, [connectionStatus, dismissToast, toastApi, t]); + + const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); + + useEffect(() => { + if (wasPaneFocusedRef.current && !isPaneFocused) { + clearOnAgentBlurRef.current(); + } + wasPaneFocusedRef.current = isPaneFocused; + }, [isPaneFocused]); + + useEffect(() => { + return () => { + if (wasPaneFocusedRef.current) { + clearOnAgentBlurRef.current(); + } + }; + }, []); + + const isInitializing = agentId ? isInitializingFromMap : false; + const isHistorySyncing = useMemo(() => { + if (!agentId || !isInitializing) { + return false; + } + const initKey = getInitKey(serverId, agentId); + return Boolean(getInitDeferred(initKey)); + }, [agentId, isInitializing, serverId]); + const needsAuthoritativeSync = useMemo(() => { + if (!agentId) { + return false; + } + return agentHistorySyncGeneration < historySyncGeneration; + }, [agentHistorySyncGeneration, agentId, historySyncGeneration]); + + // Focusing a pane only fetches when the client does not already hold the + // transcript — see `shouldSyncAgentTimelineOnFocus` for why an unconditional + // fetch here was the navigation path's most expensive redundant round-trip. + useEffect(() => { + if (!isPaneFocused || !agentId || !isConnected || !hasSession) { + return; + } + if ( + !shouldSyncAgentTimelineOnFocus({ + hasAuthoritativeHistory: hasAppliedAuthoritativeHistory, + needsAuthoritativeSync, + }) + ) { + return; + } + ensureInitializedWithSyncErrorHandling("focus"); + }, [ + agentId, + ensureInitializedWithSyncErrorHandling, + hasAppliedAuthoritativeHistory, + hasSession, + isConnected, + isPaneFocused, + needsAuthoritativeSync, + ]); + + const agent = useMemo( + () => buildChatAgentFromState(agentState, projectPlacement), + [agentState, projectPlacement], + ); + const continuity = useMemo(() => { + if (!hasActiveCreateHandoff || !agentId) { + return { kind: "none" }; + } + return { + kind: "optimistic-create", + agent: { + serverId, + id: agentId, + status: "running", + cwd: agent?.cwd ?? ".", + projectPlacement: agent?.projectPlacement ?? null, + }, + }; + }, [agent, agentId, hasActiveCreateHandoff, serverId]); + + const viewState = useAgentScreenStateMachine({ + routeKey: `${serverId}:${agentId ?? ""}`, + input: { + agent: agent ?? null, + missingAgentState, + isConnected, + isArchivingCurrentAgent, + isHistorySyncing, + needsAuthoritativeSync, + continuity, + hasHydratedHistoryBefore, + }, + }); + + const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; + const routeEntryKey = agentId ? `${serverId}:${agentId}` : null; + routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ + cachedIntent: routeBottomAnchorRequestRef.current, + routeKey: routeEntryKey, + hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, + }); + const routeBottomAnchorRequest = useMemo( + () => + deriveRouteBottomAnchorRequest({ + intent: routeBottomAnchorRequestRef.current, + effectiveAgentId: effectiveAgent?.id ?? null, + }), + [effectiveAgent?.id], + ); + + const handleComposerHeightChange = useCallback( + (_height: number) => { + if (!agentId) { + return; + } + streamViewRef.current?.prepareForViewportChange(); + }, + [agentId], + ); + + const handleMessageSent = useCallback(() => { + if (!agentId) { + return; + } + streamViewRef.current?.scrollToBottom("message-sent"); + }, [agentId]); + + useEffect(() => { + if (!agentId) { + return; + } + if (!isConnected || !hasSession) { + return; + } + const shouldSyncOnEntry = needsAuthoritativeSync || isNative; + if (!shouldSyncOnEntry) { + return; + } + + ensureInitializedWithSyncErrorHandling("entry"); + }, [ + agentId, + ensureInitializedWithSyncErrorHandling, + hasSession, + isConnected, + needsAuthoritativeSync, + ]); + + useEffect(() => { + initAttemptTokenRef.current += 1; + setMissingAgentState({ kind: "idle" }); + }, [agentId, serverId]); + + useEffect(() => { + if (!agentId) { + return; + } + if (agentState.id) { + if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + setMissingAgentState(reconcileMissingAgentStateWithPresentAgent); + } + return; + } + if (!isConnected || !hasSession) { + return; + } + if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + return; + } + + setMissingAgentState({ kind: "resolving" }); + const attemptToken = ++initAttemptTokenRef.current; + + ensureAgentIsInitialized(agentId) + .then(async () => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const currentSession = useSessionStore.getState().sessions[serverId]; + const currentAgent = + currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId); + if (!currentAgent) { + const result = await client.fetchAgent({ agentId }); + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + if (!result) { + setMissingAgentState({ + kind: "not_found", + message: `Agent not found: ${agentId}`, + }); + return; + } + storeFetchedAgentDetail({ serverId, result }); + } + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + setMissingAgentState({ kind: "idle" }); + return; + }) + .catch((error) => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setMissingAgentState({ kind: "not_found", message }); + return; + } + setMissingAgentState({ kind: "error", message }); + }); + }, [ + agentState.id, + agentId, + client, + ensureAgentIsInitialized, + hasSession, + isConnected, + missingAgentState.kind, + serverId, + ]); + + const animatedContentStyle = useMemo( + () => [styles.content, animatedKeyboardStyle], + [animatedKeyboardStyle], + ); + + const nonReadyView = renderChatAgentNonReadyView({ + viewState, + effectiveAgent, + t, + }); + if (nonReadyView) return nonReadyView; + invariant(agentId, "agent id is defined when agent content is ready"); + invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); + const agentCwd = agentState.cwd; + invariant(agentCwd, "agent cwd is defined when agent content is ready"); + const showHistorySyncOverlay = + viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "overlay"; + + return ( + + ); +} + +const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ + serverId, + agentId, + isPaneFocused, + isArchivingCurrentAgent, + agentState, + effectiveAgent, + routeBottomAnchorRequest, + hasAppliedAuthoritativeHistory, + toastApi, + toast, + dismiss, + streamViewRef, + animatedContentStyle, + handleComposerHeightChange, + handleMessageSent, + showHistorySyncOverlay, + cwd, + onAttentionInputFocus, + onAttentionPromptSend, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId: string; + isPaneFocused: boolean; + isArchivingCurrentAgent: boolean; + agentState: ChatAgentSelectedState; + effectiveAgent: AgentScreenAgent; + routeBottomAnchorRequest: RouteBottomAnchorRequest; + hasAppliedAuthoritativeHistory: boolean; + toastApi: ToastApi; + toast: ToastState | null; + dismiss: () => void; + streamViewRef: React.RefObject; + animatedContentStyle: object[]; + handleComposerHeightChange: (height: number) => void; + handleMessageSent: () => void; + showHistorySyncOverlay: boolean; + cwd: string; + onAttentionInputFocus: () => void; + onAttentionPromptSend: () => void; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; +}) { + const { t } = useTranslation(); + const rawAgentInputDraft = useAgentInputDraft({ + draftKey: buildDraftStoreKey({ + serverId, + agentId, + }), + }); + // Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out + // when only toast state changes (which does not affect any draft field). + const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } = + rawAgentInputDraft; + const agentInputDraft = useMemo( + (): AgentInputDraft => ({ + text, + setText, + attachments, + setAttachments, + clear, + isHydrated, + composerState, + }), + [text, setText, attachments, setAttachments, clear, isHydrated, composerState], + ); + const suggestedTaskRows = useSuggestedTasksForParent({ serverId, parentAgentId: agentId }); + const hasSuggestedTasks = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.suggestedTasks === true, + ); + const suggestedTaskActions = useSuggestedTaskActions({ serverId, parentAgentId: agentId }); + // The live checklist, floated pinned at the top of the chat. While it's up we + // hide its inline copy in the transcript so the same list isn't shown twice; + // once dismissed (or the feature is off) it settles back inline as history. + const pinnedTaskList = usePinnedTaskList({ serverId, agentId }); + const pinnedTaskListId = pinnedTaskList.item?.id; + // The pane, not the window, is what the composer has to fit inside — measured + // on `root`, whose height its own parent owns, so a growing composer can never + // feed back into it. + const { onLayout: onPaneLayout, height: paneHeight } = useContainerHeight(); + const streamSection = ( + + + + ); + const composerSection = ( + + + + ); + const streamContent = ( + {streamSection} + ); + const contentContainer = ( + + {streamContent} + {pinnedTaskList.item ? ( + + ) : null} + {hasSuggestedTasks ? ( + + ) : null} + + ); + + return ( + + + + {/* Above the transcript, at toolbar weight: this chat's total spend + and everything spawned under it. Off unless switched on in + Settings. See subagents/chat-metrics-bar.tsx. */} + + + {contentContainer} + + {composerSection} + + {showHistorySyncOverlay ? ( + + + + ) : null} + + + + + {isArchivingCurrentAgent ? ( + + + {t("agentPanel.states.archivingTitle")} + {t("agentPanel.states.archivingSubtitle")} + + ) : null} + + + ); +}); + +const AgentStreamSection = memo(function AgentStreamSection({ + streamViewRef, + serverId, + agentId, + agent, + hiddenTodoListId, + routeBottomAnchorRequest, + hasAppliedAuthoritativeHistory, + toast, + onOpenWorkspaceFile, +}: { + streamViewRef: React.RefObject; + serverId: string; + agentId?: string; + agent: AgentScreenAgent; + // When the pinned overlay is showing a checklist, its id is passed here so the + // inline copy is dropped from the transcript (no double render). + hiddenTodoListId?: string; + routeBottomAnchorRequest: RouteBottomAnchorRequest; + hasAppliedAuthoritativeHistory: boolean; + toast: ReturnType["api"]; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; +}) { + // While this panel slot is hidden, the selector returns the frozen tail + // reference instead of the live one, so background agents' 48ms stream + // flushes never re-render this section at all (the store notification sees + // an identical snapshot). When the panel becomes active again the context + // flip re-renders this component and the selector closure reads the live + // tail during that same render — reactive, not an imperative getState() + // snapshot, so reactivation can't freeze on a stale value. + // This slot renders the tail even while hidden (it holds a frozen reference + // to it), so it retains for as long as it is mounted — not only while active. + useAgentStreamRetention(serverId, agentId ?? null); + const isPanelActive = useRetainedPanelActive(); + const frozenStreamItemsRef = useRef(undefined); + const streamItemsRaw = useSessionStore((state) => { + if (!isPanelActive) { + return frozenStreamItemsRef.current; + } + return agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined; + }); + if (isPanelActive) { + frozenStreamItemsRef.current = streamItemsRaw; + } + const rawStreamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; + // Drop the inline copy of the checklist currently shown in the pinned overlay, + // so it isn't rendered in two places at once. When nothing is pinned this is a + // no-op and the original array reference flows through untouched. + const streamItems = useMemo(() => { + if (!hiddenTodoListId) { + return rawStreamItems; + } + const filtered = rawStreamItems.filter( + (item) => !(item.kind === "todo_list" && item.id === hiddenTodoListId), + ); + return filtered.length === rawStreamItems.length ? rawStreamItems : filtered; + }, [rawStreamItems, hiddenTodoListId]); + const pendingPermissionList = useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!agentId) { + return EMPTY_PENDING_PERMISSION_LIST; + } + const allPendingPermissions = state.sessions[serverId]?.pendingPermissions; + if (!allPendingPermissions) { + return EMPTY_PENDING_PERMISSION_LIST; + } + const filtered: PendingPermission[] = []; + for (const permission of allPendingPermissions.values()) { + if (permission.agentId === agentId) { + filtered.push(permission); + } + } + return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST; + }, + shallow, + ); + const pendingPermissions = useMemo(() => { + if (pendingPermissionList.length === 0) { + return EMPTY_PENDING_PERMISSIONS; + } + return new Map(pendingPermissionList.map((permission) => [permission.key, permission])); + }, [pendingPermissionList]); + + return ( + + ); +}); + +const AgentComposerSection = memo(function AgentComposerSection({ + agentId, + serverId, + isPaneFocused, + isArchivingCurrentAgent, + archivedAt, + cwd, + isSubmitLoading, + agentInputDraft, + onAttentionInputFocus, + onAttentionPromptSend, + onComposerHeightChange, + onMessageSent, + viewportHeight, +}: { + agentId?: string; + serverId: string; + isPaneFocused: boolean; + isArchivingCurrentAgent: boolean; + archivedAt: Date | null; + cwd: string; + isSubmitLoading: boolean; + agentInputDraft: AgentInputDraft; + onAttentionInputFocus: () => void; + onAttentionPromptSend: () => void; + onComposerHeightChange: (height: number) => void; + onMessageSent: () => void; + viewportHeight: number; +}) { + const isObserved = useSessionStore((state) => { + if (!agentId) { + return false; + } + const session = state.sessions[serverId]; + const agent = session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId); + return agent?.attend === "observed"; + }); + if (!agentId) { + return null; + } + if (archivedAt) { + return ; + } + if (isArchivingCurrentAgent) { + return null; + } + // Observed subagents (Claude Task / ultracode fan-out) are read-only: replace + // the composer with a disabled callout that only offers Stop. Interactive + // parameter controls hide themselves off the subagent's all-false + // capabilities. See projects/observed-subagents/observed-subagents.md. + if (isObserved) { + return ; + } + + return ( + + ); +}); + +function ActiveAgentComposer({ + agentId, + serverId, + isPaneFocused, + cwd, + isSubmitLoading, + agentInputDraft, + onAttentionInputFocus, + onAttentionPromptSend, + onComposerHeightChange, + onMessageSent, + viewportHeight, +}: { + agentId: string; + serverId: string; + isPaneFocused: boolean; + cwd: string; + isSubmitLoading: boolean; + agentInputDraft: AgentInputDraft; + onAttentionInputFocus: () => void; + onAttentionPromptSend: () => void; + onComposerHeightChange: (height: number) => void; + onMessageSent: () => void; + viewportHeight: number; +}) { + const insets = useSafeAreaInsets(); + const isCompactFormFactor = useIsCompactFormFactor(); + const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( + COMPACT_FORM_FACTOR_WIDTH, + { initialIsBelow: isCompactFormFactor }, + ); + const paneContext = usePaneContext(); + const { workspaceId, tabId, retargetCurrentTab } = paneContext; + const { archiveAgent } = useArchiveAgent(); + const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); + const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); + const subagentRows = useSubagentsForParent({ + serverId, + parentAgentId: agentId, + }); + const canDetachSubagents = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, + ); + const handleOpenSubagent = useCallback( + (subagentId: string) => { + navigateToAgent({ serverId, agentId: subagentId }); + }, + [serverId], + ); + const handleArchiveSubagent = useArchiveSubagent({ serverId }); + const handleStopSubagent = useStopSubagent({ serverId }); + const handleClearCompletedSubagents = useClearCompletedSubagents({ + serverId, + parentAgentId: agentId, + }); + const autoClearCompletedSubagents = useAutoClearCompletedSubagentsSetting(); + useAutoClearCompletedSubagents({ + serverId, + parentAgentId: agentId, + rows: subagentRows, + enabled: autoClearCompletedSubagents, + }); + const clearedSubagentTokens = useClearedSubagentTokens(serverId, agentId); + const handleDetachSubagent = useDetachSubagent({ serverId }); + const backgroundTaskRows = useBackgroundShellTasksForParent({ + serverId, + parentAgentId: agentId, + }); + const hasBackgroundShellTasks = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.backgroundShellTasks === true, + ); + const handleStopBackgroundTask = useStopBackgroundTask({ serverId, parentAgentId: agentId }); + const handleClearCompletedBackgroundTasks = useClearCompletedBackgroundTasks({ + serverId, + parentAgentId: agentId, + }); + // One driver per terminal group: completed and failed rows auto-clear on + // independent settings, so neither can sweep the other's rows. + const autoClearCompletedBackgroundTasks = useAutoClearCompletedBackgroundTasksSetting(); + useAutoClearCompletedBackgroundTasks({ + serverId, + parentAgentId: agentId, + rows: backgroundTaskRows, + group: "completed", + enabled: autoClearCompletedBackgroundTasks, + }); + const autoClearFailedBackgroundTasks = useAutoClearFailedBackgroundTasksSetting(); + useAutoClearCompletedBackgroundTasks({ + serverId, + parentAgentId: agentId, + rows: backgroundTaskRows, + group: "failed", + enabled: autoClearFailedBackgroundTasks, + }); + const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ + serverId, + cwd, + workspaceId, + }); + const attachmentScopeKeys = useMemo( + () => [workspaceAttachmentScopeKey], + [workspaceAttachmentScopeKey], + ); + const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); + const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); + const handleOpenWorkspaceAttachment = useCallback( + (attachment: WorkspaceComposerAttachment) => { + if (attachment.kind === "file_context") { + if (attachment.entryKind === "directory") { + return; + } + paneContext.openFileInWorkspace({ + location: { path: attachment.path }, + disposition: "main", + }); + return; + } + if (attachment.kind !== "review") { + return; + } + const checkout = { + serverId, + cwd: attachment.attachment.cwd, + isGit: true, + }; + openFileExplorerForCheckout({ + checkout, + isCompact: isCompactFormFactor, + }); + setExplorerTabForCheckout({ + ...checkout, + tab: "changes", + }); + }, + [ + isCompactFormFactor, + openFileExplorerForCheckout, + paneContext, + serverId, + setExplorerTabForCheckout, + ], + ); + + const handleClientSlashCommand = useCallback( + async (command: ClientSlashCommand) => { + const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId); + if (!agent) { + throw new Error("Agent not found"); + } + + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (workspaceKey) { + unpinWorkspaceAgent(workspaceKey, agentId); + hideWorkspaceAgent(workspaceKey, agentId); + + // /clear disables the preview button for this chat (no agent tab is + // focused anymore), so every preview server for this cwd should stop + // instantly rather than keep running orphaned — not just close their + // tabs. previewListConfig's runningServers is the source of truth for + // "is a server running", since a server can outlive its bound tab. + // External ("ext:") servers are excluded: those are port-probed + // processes the daemon never spawned (e.g. the user's own dev server — + // possibly the very Metro serving this app), and stopping one + // tree-kills whatever owns the port. Only explicit user action may + // stop an external server. + const previewClient = useSessionStore.getState().sessions[serverId]?.client ?? null; + if (previewClient) { + void (async () => { + const config = await previewClient.previewListConfig(cwd).catch(() => null); + const browsersById = useBrowserStore.getState().browsersById; + const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey); + const managedServers = (config?.runningServers ?? []).filter( + (server) => !isExternalPreviewServerId(server.serverId), + ); + for (const server of managedServers) { + void previewClient.previewStop(server.serverId).catch(() => undefined); + usePreviewRunningServersStore.getState().markStopped(serverId, server.serverId); + for (const tab of tabs) { + if ( + tab.target.kind === "browser" && + browsersById[tab.target.browserId]?.previewServerId === server.serverId + ) { + closeWorkspaceTab(workspaceKey, tab.tabId); + } + } + } + })(); + } + } + + if (command.kind === "replace-agent-with-draft") { + retargetCurrentTab({ + kind: "draft", + draftId: generateDraftId(), + setup: buildDraftAgentSetup(agent), + }); + } else if (workspaceKey) { + closeWorkspaceTab(workspaceKey, tabId); + } + + await archiveAgent({ serverId, agentId }); + }, + [ + agentId, + archiveAgent, + closeWorkspaceTab, + cwd, + hideWorkspaceAgent, + retargetCurrentTab, + serverId, + tabId, + unpinWorkspaceAgent, + workspaceId, + ], + ); + + const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ + mode: "translate", + }); + + const inputAreaStyle = useMemo( + () => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle], + [insets.bottom, composerKeyboardStyle], + ); + + return ( + + {/* Topmost card in the fanned stack (highest), yet painted first so it sits + BEHIND every flyout below it and the composer — see RateLimitWarningTrack. */} + {/* Mounted above the usage warning: highest in the fan, painted furthest + back. Context health is important but never urgent, so it yields the + position nearest the composer to the rate-limit strip. */} + + + + {hasBackgroundShellTasks ? ( + + ) : null} + {/* Top of the fan: an exiting card's absolutely-positioned web clone gets + appended after the composer in the DOM, so the composer needs the + highest explicit z-index to stay painted over it. See + COMPOSER_TRACK_LAYERS / the STACKING note in track-transition.tsx. */} + + + + + ); +} + +function AgentSessionUnavailableState({ + serverLabel, + connectionStatus, + lastError, + isUnknownDaemon = false, + t, +}: { + serverLabel: string; + connectionStatus: HostRuntimeConnectionStatus; + lastError: string | null; + isUnknownDaemon?: boolean; + t: TFunction; +}) { + if (isUnknownDaemon) { + return ( + + + + {t("agentPanel.unavailable.unknownHost", { serverLabel })} + + {t("agentPanel.unavailable.addHost")} + + + ); + } + + const isConnecting = connectionStatus === "connecting"; + const isPreparingSession = connectionStatus === "online"; + + return ( + + + {isConnecting || isPreparingSession ? ( + <> + + + {isPreparingSession + ? t("agentPanel.unavailable.preparingSession", { serverLabel }) + : t("agentPanel.unavailable.connecting", { serverLabel })} + + + {isPreparingSession + ? t("agentPanel.unavailable.showSoon") + : t("agentPanel.unavailable.showWhenOnline")} + + + ) : ( + <> + + {t("agentPanel.unavailable.reconnectingTo", { serverLabel })} + + + {t("agentPanel.unavailable.showAgainWhenReachable")} + + {lastError ? {lastError} : null} + + )} + + + ); +} + +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); + +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const foregroundColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); + +const styles = StyleSheet.create((theme) => ({ + root: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + contentContainer: { + flex: 1, + overflow: "hidden", + ...(isWeb ? { userSelect: "none" as const } : {}), + }, + content: { + flex: 1, + }, + inputAreaWrapper: { + width: "100%", + backgroundColor: theme.colors.surface0, + }, + // Highest layer in the composer fan so a dismissed card's exiting web clone — + // appended after the composer in the DOM — still paints beneath it. + composerLayer: { + zIndex: COMPOSER_TRACK_LAYERS.composer, + }, + historySyncOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: theme.colors.surface0, + alignItems: "center", + justifyContent: "center", + zIndex: 40, + }, + archivingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: "rgba(8, 10, 14, 0.86)", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[8], + gap: theme.spacing[3], + zIndex: 50, + }, + archivingTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + archivingSubtitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + loadingText: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[6], + gap: theme.spacing[3], + }, + errorContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + fontSize: theme.fontSize.lg, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + retryButton: { + marginTop: theme.spacing[4], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + }, + retryButtonText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + statusText: { + marginTop: theme.spacing[2], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + offlineTitle: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + offlineDescription: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + offlineDetails: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, +})); diff --git a/packages/app/src/panels/artifact-panel.tsx b/packages/app/src/panels/artifact-panel.tsx new file mode 100644 index 000000000..4f89fe57e --- /dev/null +++ b/packages/app/src/panels/artifact-panel.tsx @@ -0,0 +1,174 @@ +import { useCallback, useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { FileText, TriangleAlert } from "@/components/icons/material-icons"; +import { ArtifactHtmlView } from "@/components/artifacts/artifact-html-view"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useArtifactContent } from "@/artifacts/use-artifact-content"; +import { useArtifacts } from "@/artifacts/use-artifacts"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; + +function useArtifactPanelDescriptor( + target: { kind: "artifact"; artifactId: string }, + context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + const { artifacts } = useArtifacts(); + const artifact = artifacts.find( + (item) => item.serverId === context.serverId && item.id === target.artifactId, + ); + return { + label: artifact?.name?.trim() || target.artifactId, + subtitle: artifact?.description ?? "", + titleState: artifact?.status === "generating" ? "loading" : "ready", + icon: FileText, + statusBucket: artifact?.status === "generating" ? "running" : null, + }; +} + +function ArtifactPanel() { + const { serverId, target, openTab } = usePaneContext(); + invariant(target.kind === "artifact", "ArtifactPanel requires artifact target"); + const { theme } = useUnistyles(); + const { content, isLoading, error } = useArtifactContent(serverId, target.artifactId); + const { artifacts } = useArtifacts(); + const artifact = artifacts.find( + (item) => item.serverId === serverId && item.id === target.artifactId, + ); + const generationAgentId = artifact?.generationAgentId ?? null; + const handleViewGenerationLog = useCallback(() => { + if (generationAgentId) { + openTab({ kind: "agent", agentId: generationAgentId }); + } + }, [generationAgentId, openTab]); + + const messageStyle = useMemo( + () => [styles.message, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + const errorStyle = useMemo( + () => [styles.message, { color: theme.colors.palette.red[500] }], + [theme.colors.palette.red], + ); + + // While generating, the HTML file doesn't exist yet, so the content fetch + // legitimately 404s — key off the artifact's own status rather than the + // content query's loading/error state so this doesn't flicker into the + // error branch below once retries are exhausted. + if (artifact?.status === "generating") { + return ( + + + Generating… + {generationAgentId ? ( + + ) : null} + + ); + } + + if (isLoading && content === null) { + return ( + + + + ); + } + + // A failed generation with no recoverable content (first-ever generation, + // or a failed regeneration with nothing to fall back to) has nothing to + // render — show the failure plainly instead of a generic fetch error. + if (artifact?.status === "error" && !content) { + return ( + + + {artifact.errorMessage ?? error ?? "Generation failed"} + {generationAgentId ? ( + + ) : null} + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!content) { + return ( + + This artifact has no content yet. + + ); + } + + return ( + + {artifact?.status === "error" ? ( + + + + {artifact.errorMessage ?? "Regeneration failed — showing the last successful version."} + + {generationAgentId ? ( + + ) : null} + + ) : null} + + + ); +} + +export const artifactPanelRegistration: PanelRegistration<"artifact"> = { + kind: "artifact", + component: ArtifactPanel, + useDescriptor: useArtifactPanelDescriptor, + confirmClose() { + return Promise.resolve(true); + }, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 16, + backgroundColor: theme.colors.surface0, + }, + message: { + fontSize: 13, + textAlign: "center", + }, + errorBanner: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + padding: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + errorBannerText: { + flex: 1, + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + }, +})); diff --git a/packages/app/src/panels/browser-panel.tsx b/packages/app/src/panels/browser-panel.tsx new file mode 100644 index 000000000..f9726b08f --- /dev/null +++ b/packages/app/src/panels/browser-panel.tsx @@ -0,0 +1,85 @@ +import { useMemo } from "react"; +import { Image } from "react-native"; +import { Globe, Play } from "@/components/icons/material-icons"; +import invariant from "tiny-invariant"; +import { BrowserPane } from "@/components/browser-pane"; +import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry"; +import { useBrowserStore } from "@/stores/browser-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; + +function getBrowserLabel(input: { title: string; url: string }): string { + const title = input.title.trim(); + if (title) { + return title; + } + + try { + const parsed = new URL(input.url); + return parsed.hostname || input.url; + } catch { + return input.url; + } +} + +function createBrowserTabIcon(input: { faviconUrl: string | null; isPreview: boolean }) { + return function BrowserTabIcon({ size, color }: PanelIconProps) { + const source = useMemo(() => (input.faviconUrl ? { uri: input.faviconUrl } : undefined), []); + const imageStyle = useMemo(() => ({ width: size, height: size, borderRadius: 3 }), [size]); + + // Preview tabs always show Play, even once a favicon loads, so they stay + // visually distinct from tabs the user opened themselves. + if (input.isPreview) { + return ; + } + + if (input.faviconUrl) { + return ; + } + + return ; + }; +} + +function useBrowserPanelDescriptor(target: { + kind: "browser"; + browserId: string; +}): PanelDescriptor { + const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null); + const url = browser?.url ?? "https://example.com"; + const icon = createBrowserTabIcon({ + faviconUrl: browser?.faviconUrl ?? null, + isPreview: browser?.isPreview ?? false, + }); + + return { + label: getBrowserLabel({ title: browser?.title ?? "", url }), + subtitle: url, + titleState: "ready", + icon, + statusBucket: browser?.isLoading ? "running" : null, + }; +} + +function BrowserPanel() { + const { serverId, workspaceId, target } = usePaneContext(); + const { focusPane, isInteractive } = usePaneFocus(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + invariant(target.kind === "browser", "BrowserPanel requires browser target"); + return ( + + ); +} + +export const browserPanelRegistration: PanelRegistration<"browser"> = { + kind: "browser", + component: BrowserPanel, + useDescriptor: useBrowserPanelDescriptor, +}; diff --git a/packages/app/src/panels/code-references-panel.tsx b/packages/app/src/panels/code-references-panel.tsx new file mode 100644 index 000000000..d5aacfb15 --- /dev/null +++ b/packages/app/src/panels/code-references-panel.tsx @@ -0,0 +1,454 @@ +import { useCallback, useMemo, useRef, type ReactNode } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import type { CodeDefinitionLocation } from "@otto-code/protocol/messages"; +import { RotateCw, Search } from "@/components/icons/material-icons"; +import { PANE_TOOLBAR_HEIGHT } from "@/components/ui/control-geometry"; +import { ToolbarIconButton } from "@/components/ui/toolbar-icon-button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { isWeb } from "@/constants/platform"; +import { compactFont } from "@/styles/theme"; +import { + CodeResultExpandToggle, + CodeResultGroupHeader, + CodeResultRow, + useCollapsedGroups, +} from "@/editor/code-results/result-rows"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { + useCodeReferences, + type CodeReferencesGroup, +} from "@/editor/references/use-code-references"; +import { + useReferencePreviews, + type CodeLinePreview, + type PreviewsByPath, +} from "@/editor/references/use-reference-previews"; + +/** + * Every reference to one symbol, as a results tab you navigate FROM. + * + * A tab rather than a dialog, for the reason git-file-history already argues: this is a + * working surface you keep open beside the code, and visiting a hit is the whole point — a + * dialog would be dismissed by the very act of using it. One tab per (path, line, column), + * so a second search does not evict the first. + * + * Grouped by file rather than a flat table. A flat list of 40 rows repeats the same path 12 + * times and buries the fact that the symbol touches four files, which is usually the first + * thing you wanted to know. + * + * Strings are literal English pending the pre-release i18n sweep. + */ + +const ThemedRotateCw = withUnistyles(RotateCw); + +type CodeReferencesTarget = Extract; + +function useCodeReferencesPanelDescriptor(target: CodeReferencesTarget): PanelDescriptor { + return { + label: `References: ${target.symbol}`, + subtitle: `${target.path}:${target.line}`, + titleState: "ready", + icon: Search, + statusBucket: null, + }; +} + +function CodeReferencesPanel() { + const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); + invariant(target.kind === "codeReferences", "CodeReferencesPanel requires codeReferences target"); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const hasLsp = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.lsp === true, + ); + + const references = useCodeReferences({ + serverId, + cwd: cwd ?? "", + path: target.path, + line: target.line, + column: target.column, + enabled: hasLsp && Boolean(cwd), + }); + + const previews = useReferencePreviews({ + serverId, + cwd: cwd ?? "", + groups: references.groups, + enabled: hasLsp && Boolean(cwd), + }); + + const groups = useCollapsedGroups(); + const { allExpanded, toggleAll } = groups; + const paths = useMemo(() => references.groups.map((group) => group.path), [references.groups]); + const toggleEverything = useCallback(() => toggleAll(paths), [paths, toggleAll]); + + const openHit = useCallback( + (hit: CodeDefinitionLocation) => { + openFileInWorkspace({ + location: { path: hit.path, lineStart: hit.line }, + disposition: "main", + }); + }, + [openFileInWorkspace], + ); + + if (!hasLsp) { + return ( + + Update the host to use code intelligence. + + ); + } + + return ( + + + + + + + ); +} + +/** + * Counts first, because "how many places and how many files" is the question a references + * search is usually standing in for. + * + * The provisional chip is not decoration. The daemon reports `indexing` while a language + * server is still building its project model, and a list captured then can be a fraction of + * the truth — measured at 2 hits in 1 file where the real answer was 14 in 4. Showing a count + * without saying it is still settling is how a search quietly lies. + */ +function ReferencesToolbar({ + symbol, + hitCount, + fileCount, + provisional, + gaveUp, + onRefresh, + allExpanded, + onToggleAll, +}: { + symbol: string; + hitCount: number; + fileCount: number; + provisional: boolean; + gaveUp: boolean; + onRefresh: () => void; + allExpanded: boolean; + onToggleAll: () => void; +}) { + return ( + + + {symbol} + + + {`${hitCount} ${hitCount === 1 ? "reference" : "references"} in ${fileCount} ${ + fileCount === 1 ? "file" : "files" + }`} + + {provisional ? ( + + + Still loading + + + + A language server is still building its project model, so this list may be incomplete. + It refreshes itself as the project settles. + + + + ) : null} + {gaveUp ? ( + + + May be incomplete + + + + The project never finished loading, so this list is what the server could answer. + Refresh to ask again. + + + + ) : null} + + + + + ); +} + +/** Error / first-load / empty / content, in that order of precedence. */ +function ResultsBody({ + references, + children, +}: { + references: ReturnType; + children: ReactNode; +}): ReactNode { + if (references.unavailable) { + return ( + + + No language server on the host covers this file, so it cannot resolve references. + + + ); + } + if (references.error !== null) { + return ( + + {references.error} + + ); + } + if (references.loading && references.hitCount === 0) { + return ( + + Searching… + + ); + } + if (references.hitCount === 0) { + return ( + + No references found. + + ); + } + return children; +} + +function ResultsList({ + groups, + previews, + isCollapsed, + onToggleGroup, + onOpen, +}: { + groups: readonly CodeReferencesGroup[]; + previews: PreviewsByPath; + isCollapsed: (path: string) => boolean; + onToggleGroup: (path: string) => void; + onOpen: (hit: CodeDefinitionLocation) => void; +}) { + const scrollRef = useRef(null); + const scrollbar = useWebScrollViewScrollbar(scrollRef, { enabled: isWeb }); + + return ( + + + {groups.map((group) => ( + + ))} + + {scrollbar.overlay} + + ); +} + +function FileGroup({ + group, + preview, + collapsed, + onToggle, + onOpen, +}: { + group: CodeReferencesGroup; + preview: Record | undefined; + collapsed: boolean; + onToggle: (path: string) => void; + onOpen: (hit: CodeDefinitionLocation) => void; +}) { + return ( + + + {collapsed + ? null + : group.hits.map((hit) => ( + + ))} + + ); +} + +function HitRow({ + hit, + preview, + onOpen, +}: { + hit: CodeDefinitionLocation; + preview: CodeLinePreview | undefined; + onOpen: (hit: CodeDefinitionLocation) => void; +}) { + const open = useCallback(() => onOpen(hit), [hit, onOpen]); + + return ( + + ); +} + +export const codeReferencesPanelRegistration: PanelRegistration<"codeReferences"> = { + kind: "codeReferences", + component: CodeReferencesPanel, + useDescriptor: useCodeReferencesPanelDescriptor, + confirmClose() { + return Promise.resolve(true); + }, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.background, + }, + // Same geometry as the file editor's toolbar, down to the padding: these tabs + // open beside the editor in a split, and a bar that is a few pixels off reads + // as a mistake in the split, not as a different panel. + toolbar: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + minHeight: PANE_TOOLBAR_HEIGHT, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + toolbarSpacer: { + flex: 1, + }, + symbolText: { + color: theme.colors.foreground, + fontFamily: theme.fontFamily.mono, + fontSize: compactFont(theme.fontSize.sm), + fontWeight: theme.fontWeight.semibold, + flexShrink: 0, + }, + countText: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + flexShrink: 1, + }, + provisionalChip: { + borderWidth: 1, + borderColor: theme.colors.statusWarning, + borderRadius: theme.borderRadius.sm, + paddingHorizontal: theme.spacing[2], + paddingVertical: 1, + }, + provisionalText: { + color: theme.colors.statusWarning, + fontSize: compactFont(theme.fontSize.xs), + }, + staleChip: { + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.sm, + paddingHorizontal: theme.spacing[2], + paddingVertical: 1, + }, + staleText: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.xs), + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: compactFont(theme.fontSize.xs), + }, + listHost: { + flex: 1, + minHeight: 0, + position: "relative", + }, + listScroll: { + flex: 1, + }, + group: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + textAlign: "center", + }, + errorText: { + color: theme.colors.destructive, + fontSize: compactFont(theme.fontSize.sm), + textAlign: "center", + }, +})); diff --git a/packages/app/src/panels/code-rename-panel.tsx b/packages/app/src/panels/code-rename-panel.tsx new file mode 100644 index 000000000..41d134cd2 --- /dev/null +++ b/packages/app/src/panels/code-rename-panel.tsx @@ -0,0 +1,666 @@ +import type { CodeRenameFilePlan } from "@otto-code/protocol/messages"; +import { useCallback, useMemo, useRef } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { Check, Pencil, RotateCw, Undo2, X } from "@/components/icons/material-icons"; +import { PANE_TOOLBAR_HEIGHT } from "@/components/ui/control-geometry"; +import { ToolbarIconButton } from "@/components/ui/toolbar-icon-button"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { isWeb } from "@/constants/platform"; +import { compactFont } from "@/styles/theme"; +import { + CodeResultExpandToggle, + CodeResultGroupHeader, + CodeResultRow, + splitPath, + useCollapsedGroups, +} from "@/editor/code-results/result-rows"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { CodeRenameApplyOutcome, CodeRenameUndoOutcome } from "@otto-code/client"; +import { useCodeRenameJob, type RenameJobPhase } from "@/editor/rename/use-code-rename-job"; + +/** + * A rename as an auditable job. + * + * The whole design is the inverse of an inline rename box: the request is taken from the + * file, set up as a job in its own tab, and the full dry run — every file and every edit — + * is shown before anything happens. An inline box hides a project-wide blast radius behind + * one keystroke; this makes the blast radius the thing you are looking at when you decide. + * + * Nothing is written until Apply, and Apply sends back only the plan's identity, so the + * daemon can refuse if what it would write is no longer what was reviewed. + * + * Strings are literal English pending the pre-release i18n sweep. + */ + +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedCheck = withUnistyles(Check); +const ThemedUndo2 = withUnistyles(Undo2); +const ThemedX = withUnistyles(X); + +type CodeRenameTarget = Extract; + +function useCodeRenamePanelDescriptor(target: CodeRenameTarget): PanelDescriptor { + return { + label: `Rename: ${target.symbol}`, + subtitle: `→ ${target.newName}`, + titleState: "ready", + // Pencil, not SquarePen: SquarePen is the chat/draft tab's icon, so a rename job sitting + // next to a New Chat tab would read as a second one. Pencil is already the repo's rename + // glyph (tab context menu, sidebar workspace rename) and is unused by any other tab. + icon: Pencil, + statusBucket: null, + }; +} + +function CodeRenamePanel() { + const { serverId, workspaceId, target, openFileInWorkspace, closeCurrentTab } = usePaneContext(); + invariant(target.kind === "codeRename", "CodeRenamePanel requires codeRename target"); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const hasLsp = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.lsp === true, + ); + + const job = useCodeRenameJob({ + serverId, + cwd: cwd ?? "", + path: target.path, + line: target.line, + column: target.column, + newName: target.newName, + enabled: hasLsp && Boolean(cwd), + }); + + const groups = useCollapsedGroups(); + const { allExpanded, toggleAll } = groups; + const paths = useMemo(() => job.files.map((file) => file.path), [job.files]); + const toggleEverything = useCallback(() => toggleAll(paths), [paths, toggleAll]); + + const openAt = useCallback( + (path: string, line: number) => { + openFileInWorkspace({ location: { path, lineStart: line }, disposition: "main" }); + }, + [openFileInWorkspace], + ); + + if (!hasLsp) { + return ( + + Update the host to use code intelligence. + + ); + } + + return ( + + + + + + + ); +} + +/** + * Impact first, action second — and the Apply button is only ever enabled against a plan + * the panel is actually showing. Every other phase disables it, because "apply" with + * nothing on screen to apply is the exact failure the tab exists to prevent. + * + * One row, at the file editor's exact toolbar height. This tab is opened beside the editor + * in a split, so its chrome is read against the editor's — the impact line used to sit on a + * second row, which made the bar visibly taller than the one next to it. + * + * The action is a `ToolbarIconButton` like every other button here, accent-tinted rather + * than a labelled CTA. A text button is taller than the icons beside it and would set the + * bar's height by itself; the label it used to carry is now its tooltip, so nothing about + * the action is less discoverable than the toolbar's other buttons. + * + * After an undo the tab is finished: `onReplan` is withdrawn and the action slot becomes + * Close. Re-planning at that point asks a language server whose in-memory copy of the file + * still holds the applied rename — the daemon writes and restores those files itself and + * never tells the server — so it answers about a symbol that is no longer there. Offering a + * button that cannot work is worse than not offering one. + */ +function RenameHeader({ + symbol, + newName, + fileCount, + editCount, + phase, + onApply, + onUndo, + onReplan, + onClose, + allExpanded, + onToggleAll, +}: { + symbol: string; + newName: string; + fileCount: number; + editCount: number; + phase: RenameJobPhase; + onApply: () => void; + onUndo: () => void; + onReplan: () => void; + onClose: () => void; + allExpanded: boolean; + onToggleAll: () => void; +}) { + const isFinished = phase.kind === "undone"; + const isRun = phase.kind === "ran" || phase.kind === "undoing"; + + return ( + + + {symbol} + + + + {newName} + + + {summarizePhase(phase, fileCount, editCount)} + + + {/* Only while the plan itself is on screen — the run and undo reports are flat + lists of files with nothing to fold. */} + {phase.kind === "ready" ? ( + + ) : null} + {isFinished ? null : ( + + )} + {/* One action slot, whose meaning follows the phase. A tab that showed Apply and + Undo at once would be asking the user to work out which one is live. */} + + + ); +} + +function RenameAction({ + phase, + isFinished, + isRun, + onApply, + onUndo, + onClose, +}: { + phase: RenameJobPhase; + isFinished: boolean; + isRun: boolean; + onApply: () => void; + onUndo: () => void; + onClose: () => void; +}) { + if (isFinished) { + return ( + + ); + } + if (isRun) { + // Undo is the only action left once the rename has run, so it keeps the accent — + // there is nothing else in the bar for the tint to compete with. + return ( + + ); + } + return ( + + ); +} + +function PlanBody({ phase, children }: { phase: RenameJobPhase; children: React.ReactNode }) { + if (phase.kind === "planning") { + return ( + + + {phase.waitingForProject + ? "Waiting for the project to finish loading — a plan made now would under-report what this rename touches." + : "Working out what this rename would change…"} + + + ); + } + if (phase.kind === "failed") { + return ( + + {phase.reason} + + ); + } + if (phase.kind === "ran" || phase.kind === "undoing") { + return ; + } + if (phase.kind === "undone") { + return ; + } + return children; +} + +/** + * What the run actually did, file by file. + * + * Every file is listed, including the ones that came out clean — a report that showed only + * problems would leave the user unable to tell "nothing went wrong" from "nothing ran". + */ +function RunReport({ outcome }: { outcome: CodeRenameApplyOutcome }) { + const scrollRef = useRef(null); + const scrollbar = useWebScrollViewScrollbar(scrollRef, { enabled: isWeb }); + + return ( + + + {outcome.files.map((file) => ( + + ))} + + {scrollbar.overlay} + + ); +} + +/** + * What the undo did. `changedSince` is the row that matters: the file was edited after the + * run, so restoring it would have destroyed that work and it was deliberately left alone. + */ +function UndoReport({ undo }: { undo: CodeRenameUndoOutcome }) { + return ( + + + {undo.files.map((file) => ( + + ))} + + + ); +} + +function OutcomeRow({ + path, + tone, + status, + reason, +}: { + path: string; + tone: "good" | "warn" | "bad"; + status: string; + reason: string | null; +}) { + const { head, tail } = useMemo(() => splitPath(path), [path]); + + return ( + + + + + {tail} + + + {head} + + + + {status} + + + {reason ? ( + + {reason} + + ) : null} + + ); +} + +const RUN_TONE = { applied: "good", partial: "warn", failed: "bad" } as const; + +function describeRunFile(file: CodeRenameApplyOutcome["files"][number]): string { + if (file.kind === "applied") { + return `${file.appliedEdits} applied`; + } + if (file.kind === "partial") { + return `${file.appliedEdits} applied, ${file.skippedEdits} skipped`; + } + return "not changed"; +} + +const UNDO_TONE = { restored: "good", changedSince: "warn", failed: "bad" } as const; +const UNDO_STATUS = { + restored: "put back", + changedSince: "left as it is", + failed: "could not undo", +} as const; + +const TONE_DOT = { good: "dotGood", warn: "dotWarn", bad: "dotBad" } as const; +const TONE_TEXT = { good: "statusGood", warn: "statusWarn", bad: "statusBad" } as const; + +/** The one line that answers "what is this tab telling me" for every phase. */ +function summarizePhase(phase: RenameJobPhase, fileCount: number, editCount: number): string { + if (phase.kind === "ready") { + const edits = `${editCount} ${editCount === 1 ? "edit" : "edits"}`; + const files = `${fileCount} ${fileCount === 1 ? "file" : "files"}`; + return `${edits} across ${files}. Nothing has been written yet.`; + } + if (phase.kind === "ran" || phase.kind === "undoing") { + const { appliedEdits, skippedEdits, appliedFiles, complete } = phase.outcome; + if (complete) { + return `Done — ${appliedEdits} ${appliedEdits === 1 ? "edit" : "edits"} written across ${appliedFiles} ${appliedFiles === 1 ? "file" : "files"}.`; + } + return `${appliedEdits} written, ${skippedEdits} skipped because the files had changed. Undo puts back only what this run wrote.`; + } + if (phase.kind === "undone") { + return phase.undo.complete + ? `Undone — ${phase.undo.restoredFiles} ${phase.undo.restoredFiles === 1 ? "file" : "files"} put back.` + : `${phase.undo.restoredFiles} put back. The rest were edited after the rename and were left alone.`; + } + return ""; +} + +function PlanList({ + files, + newName, + isCollapsed, + onToggleGroup, + onOpen, +}: { + files: readonly CodeRenameFilePlan[]; + newName: string; + isCollapsed: (path: string) => boolean; + onToggleGroup: (path: string) => void; + onOpen: (path: string, line: number) => void; +}) { + const scrollRef = useRef(null); + const scrollbar = useWebScrollViewScrollbar(scrollRef, { enabled: isWeb }); + + return ( + + + {files.map((file) => ( + + ))} + + {scrollbar.overlay} + + ); +} + +function FileGroup({ + file, + newName, + collapsed, + onToggle, + onOpen, +}: { + file: CodeRenameFilePlan; + newName: string; + collapsed: boolean; + onToggle: (path: string) => void; + onOpen: (path: string, line: number) => void; +}) { + return ( + + + {collapsed + ? null + : file.edits.map((edit) => ( + + ))} + + ); +} + +function EditRow({ + path, + line, + column, + newText, + onOpen, +}: { + path: string; + line: number; + column: number; + newText: string; + onOpen: (path: string, line: number) => void; +}) { + const open = useCallback(() => onOpen(path, line), [line, onOpen, path]); + + return ( + + ); +} + +export const codeRenamePanelRegistration: PanelRegistration<"codeRename"> = { + kind: "codeRename", + component: CodeRenamePanel, + useDescriptor: useCodeRenamePanelDescriptor, + confirmClose() { + return Promise.resolve(true); + }, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.background, + }, + // Same geometry as the file editor's toolbar, down to the padding: this tab + // opens beside the editor in a split, and a bar that is a few pixels off reads + // as a mistake in the split, not as a different panel. + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + minHeight: PANE_TOOLBAR_HEIGHT, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + spacer: { + flex: 1, + }, + oldName: { + color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, + fontSize: compactFont(theme.fontSize.sm), + textDecorationLine: "line-through", + flexShrink: 1, + }, + arrow: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + }, + newName: { + color: theme.colors.foreground, + fontFamily: theme.fontFamily.mono, + fontSize: compactFont(theme.fontSize.sm), + fontWeight: "600", + flexShrink: 1, + }, + // Shrinks before the names do — the symbol being renamed is what identifies + // the tab, so it is the last thing that should be truncated. + impactText: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + flexShrink: 2, + }, + listHost: { + flex: 1, + minHeight: 0, + position: "relative", + }, + listScroll: { + flex: 1, + }, + group: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + textAlign: "center", + maxWidth: 520, + }, + errorText: { + color: theme.colors.destructive, + fontSize: compactFont(theme.fontSize.sm), + textAlign: "center", + maxWidth: 520, + }, + outcomeRow: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + gap: 2, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + outcomeHead: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + outcomeName: { + color: theme.colors.foreground, + fontSize: compactFont(theme.fontSize.sm), + fontWeight: theme.fontWeight.semibold, + flexShrink: 0, + }, + outcomeDir: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + flexShrink: 1, + }, + outcomeReason: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + paddingLeft: theme.spacing[3], + }, + dotGood: { width: 7, height: 7, borderRadius: 999, backgroundColor: theme.colors.statusSuccess }, + dotWarn: { width: 7, height: 7, borderRadius: 999, backgroundColor: theme.colors.statusWarning }, + dotBad: { width: 7, height: 7, borderRadius: 999, backgroundColor: theme.colors.statusDanger }, + statusGood: { color: theme.colors.statusSuccess, fontSize: compactFont(theme.fontSize.sm) }, + statusWarn: { color: theme.colors.statusWarning, fontSize: compactFont(theme.fontSize.sm) }, + statusBad: { color: theme.colors.statusDanger, fontSize: compactFont(theme.fontSize.sm) }, +})); diff --git a/packages/app/src/panels/context-management-panel-registration.tsx b/packages/app/src/panels/context-management-panel-registration.tsx new file mode 100644 index 000000000..e127c9430 --- /dev/null +++ b/packages/app/src/panels/context-management-panel-registration.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { BookOpen } from "@/components/icons/material-icons"; +import { ContextManagementPanel } from "@/context-management/panel"; +import type { PanelDescriptor, PanelRegistration } from "./panel-registry"; + +function useContextManagementPanelDescriptor(): PanelDescriptor { + const { t } = useTranslation(); + return { + label: t("workspace.contextManagement.tabLabel"), + subtitle: t("workspace.contextManagement.subtitle"), + titleState: "ready", + // Not Gauge — that reads as Metrics, which owns it in the stats nav and settings. + icon: BookOpen, + statusBucket: null, + }; +} + +export const contextManagementPanelRegistration: PanelRegistration<"contextManagement"> = { + kind: "contextManagement", + component: ContextManagementPanel, + useDescriptor: useContextManagementPanelDescriptor, + // Nothing unsaved lives in this panel itself — the embedded file pane owns + // its own buffer and dirty-state prompting. + confirmClose() { + return Promise.resolve(true); + }, +}; diff --git a/packages/app/src/panels/draft-panel-descriptor.ts b/packages/app/src/panels/draft-panel-descriptor.ts new file mode 100644 index 000000000..15be856af --- /dev/null +++ b/packages/app/src/panels/draft-panel-descriptor.ts @@ -0,0 +1,30 @@ +import type { ComponentType } from "react"; +import { i18n } from "@/i18n/i18next"; +import type { PanelDescriptor, PanelIconProps } from "@/panels/panel-registry"; + +export function buildDraftPanelDescriptor(input: { + isCreating: boolean; + pendingPrompt?: string | null; + icon: ComponentType; +}): PanelDescriptor { + const { icon, isCreating, pendingPrompt } = input; + const newAgentLabel = i18n.t("panels.draft.newAgent"); + const creatingLabel = pendingPrompt?.trim() || newAgentLabel; + if (isCreating) { + return { + label: creatingLabel, + subtitle: i18n.t("panels.draft.creatingAgent"), + titleState: "ready", + icon, + statusBucket: "running", + }; + } + + return { + label: newAgentLabel, + subtitle: newAgentLabel, + titleState: "ready", + icon, + statusBucket: null, + }; +} diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx new file mode 100644 index 000000000..6bba35683 --- /dev/null +++ b/packages/app/src/panels/draft-panel.tsx @@ -0,0 +1,8 @@ +import { AgentConversationPanel, useDraftPanelDescriptor } from "@/panels/agent-panel"; +import type { PanelRegistration } from "@/panels/panel-registry"; + +export const draftPanelRegistration: PanelRegistration<"draft"> = { + kind: "draft", + component: AgentConversationPanel, + useDescriptor: useDraftPanelDescriptor, +}; diff --git a/packages/app/src/panels/file-history-panel.tsx b/packages/app/src/panels/file-history-panel.tsx new file mode 100644 index 000000000..9c1e700f1 --- /dev/null +++ b/packages/app/src/panels/file-history-panel.tsx @@ -0,0 +1,538 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import invariant from "tiny-invariant"; +import type { GitBlameCommit, GitFileHistoryEntry } from "@otto-code/protocol/messages"; +import { History, Pilcrow, RotateCw } from "@/components/icons/material-icons"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ResizeHandle } from "@/components/resize-handle"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { compactFont, compactUp, useIconSize, type Theme } from "@/styles/theme"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { useFileHistoryLayoutStore } from "@/stores/file-history-layout-store"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { CommitDetail } from "@/git/file-history/commit-detail"; +import { CommitTable, CommitTableHeader } from "@/git/file-history/commit-table"; +import { RevisionDiff, type RevisionFocus } from "@/git/file-history/revision-diff"; +import { useFileHistory, useFileOrigin } from "@/git/file-history/use-file-history-data"; + +/** + * Git investigation for one file, as a stack of three resizable panes: the + * commit table, the selected revision's diff, and that commit's full message. + * + * Stacked rather than side by side because a diff is a wide thing. Putting the + * commit list beside it spends horizontal space — the axis code actually needs — + * on four narrow columns, and then every line of the diff wraps or scrolls. The + * list is short and wide, the diff is tall and wide; they belong on top of each + * other. + * + * Blame is not a separate view here: it annotates the diff's gutter, beside the + * lines it describes. See revision-diff-body.tsx. + * + * Local git only, and provider-neutral: see docs/git-file-history.md. + */ + +const RESIZE_GROUP_ID = "file-history-split"; + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const accentColorMapping = (theme: Theme) => ({ color: theme.colors.accent }); + +const ThemedPilcrow = withUnistyles(Pilcrow); +const ThemedRotateCw = withUnistyles(RotateCw); + +// Derived from the tab union rather than restated, so a change to the target +// shape reaches this panel as a type error instead of a silent drift. +type FileHistoryTarget = Extract; + +function fileHistoryTabTitle(target: FileHistoryTarget): string { + const name = target.path.split("/").at(-1) ?? target.path; + return `History: ${name}`; +} + +function useFileHistoryPanelDescriptor(target: FileHistoryTarget): PanelDescriptor { + const { t } = useTranslation(); + const hasRange = target.startLine !== undefined && target.endLine !== undefined; + return { + label: fileHistoryTabTitle(target), + subtitle: hasRange + ? t("gitFileHistory.scopeLines", { start: target.startLine, end: target.endLine }) + : target.path, + titleState: "ready", + icon: History, + statusBucket: null, + }; +} + +function FileHistoryPanel() { + const { serverId, workspaceId, target } = usePaneContext(); + invariant(target.kind === "fileHistory", "FileHistoryPanel requires fileHistory target"); + const { t } = useTranslation(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const isCompact = useIsCompactFormFactor(); + const supported = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutGitFileHistory === true, + ); + + const [selected, setSelected] = useState(null); + const [blameFocus, setBlameFocus] = useState(null); + const [ignoreWhitespace, setIgnoreWhitespace] = useState(false); + // Bumped to re-run the queries; the data hooks are one-shot reads with no + // push channel, so a refresh is an explicit act. + const [reloadToken, setReloadToken] = useState(0); + + const sizes = useFileHistoryLayoutStore((state) => state.sizes); + const setSizes = useFileHistoryLayoutStore((state) => state.setSizes); + + const range = useMemo( + () => + target.startLine !== undefined && target.endLine !== undefined + ? { startLine: target.startLine, endLine: target.endLine } + : null, + [target.startLine, target.endLine], + ); + + const notARepoLabel = t("gitFileHistory.notARepo"); + const history = useFileHistory({ + serverId, + cwd: cwd ?? "", + path: target.path, + range, + enabled: supported && Boolean(cwd), + reloadToken, + notARepoLabel, + }); + const origin = useFileOrigin({ + serverId, + cwd: cwd ?? "", + path: target.path, + enabled: supported && Boolean(cwd) && range === null, + notARepoLabel, + }); + + // Land on the newest commit so the diff pane has something in it the moment + // the tab opens, instead of an empty half-screen asking to be clicked. + const firstEntry = history.entries[0]; + useEffect(() => { + setSelected((current) => current ?? firstEntry ?? null); + }, [firstEntry]); + + const handleSelectEntry = useCallback((entry: GitFileHistoryEntry) => { + setBlameFocus(null); + setSelected(entry); + }, []); + + const handleSelectBlameCommit = useCallback( + (commit: GitBlameCommit) => { + // Blame knows the sha and the file's name in that commit, but nothing + // else. If the sha is one the history already loaded, reuse that entry so + // the message pane fills in too; otherwise show what blame does know. + const known = history.entries.find((entry) => entry.sha === commit.sha) ?? null; + setSelected(known); + setBlameFocus( + known + ? null + : { + sha: commit.sha, + shortSha: commit.shortSha, + path: commit.path ?? target.path, + }, + ); + }, + [history.entries, target.path], + ); + + const handleResize = useCallback( + (_groupId: string, nextSizes: number[]) => setSizes(nextSizes), + [setSizes], + ); + const toggleWhitespace = useCallback(() => setIgnoreWhitespace((current) => !current), []); + const handleRefresh = useCallback(() => setReloadToken((current) => current + 1), []); + + const focus: RevisionFocus | null = blameFocus ?? entryToFocus(selected); + + // One share model for every pane: each flexes from a zero basis, so a stored + // ratio means the same fraction of the stack no matter how tall the pane is. + const listPaneStyle = usePaneStyle(styles.listPane, sizes[0]); + const diffPaneStyle = usePaneStyle(styles.diffPane, sizes[1]); + const detailPaneStyle = usePaneStyle(styles.detailPane, sizes[2]); + + if (!supported) { + return ( + + {t("gitFileHistory.unsupportedHost")} + + ); + } + + return ( + + + + + + + {isCompact ? null : ( + + )} + + + + {isCompact ? null : ( + + )} + + + + + + ); +} + +/** A pane's share of the stack, as a flex grow from a zero basis. */ +function usePaneStyle(base: object, share: number | undefined) { + return useMemo( + () => [base, inlineUnistylesStyle({ flexGrow: share ?? 1, flexBasis: 0 })], + [base, share], + ); +} + +/** + * The pane's own toolbar. The origin commit sits here rather than as a banner + * inside the list — it is a fact about the file, true regardless of which commit + * is selected, so it belongs with the file's other facts. + */ +function FileHistoryToolbar({ + range, + origin, + ignoreWhitespace, + onToggleWhitespace, + onRefresh, +}: { + range: { startLine: number; endLine: number } | null; + origin: GitFileHistoryEntry | null; + ignoreWhitespace: boolean; + onToggleWhitespace: () => void; + onRefresh: () => void; +}) { + const { t } = useTranslation(); + const iconSize = useIconSize(); + return ( + + {range ? ( + + {t("gitFileHistory.scopeLines", { start: range.startLine, end: range.endLine })} + + ) : null} + {origin ? ( + + {t("gitFileHistory.originBy", { + author: origin.authorName, + when: new Date(origin.authoredAt * 1000).toLocaleDateString(), + })} + + ) : null} + + + + + + + + + ); +} + +/** + * An icon-only toolbar control with a tooltip, matching the Changes toolbar. + * `active` gives a toggle a visible on-state — an icon toggle that looks + * identical in both states is a switch you cannot read. + */ +function ToolbarIconButton({ + label, + active = false, + onPress, + testID, + children, +}: { + label: string; + active?: boolean; + onPress: () => void; + testID?: string; + children: ReactNode; +}) { + const buttonStyle = useCallback( + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ + styles.toolbarButton, + (Boolean(hovered) || pressed) && styles.toolbarButtonHovered, + active && styles.toolbarButtonActive, + ], + [active], + ); + return ( + + + {children} + + + {label} + + + ); +} + +/** + * The commit table. The column header is pinned outside the scroll view — a + * header that scrolls away stops naming anything the moment you use the table. + */ +function ListPane({ + history, + selectedSha, + onSelectEntry, +}: { + history: ReturnType; + selectedSha: string | null; + onSelectEntry: (entry: GitFileHistoryEntry) => void; +}) { + const { t } = useTranslation(); + const scrollRef = useRef(null); + // Not gated on form factor: a narrow browser window still draws the platform's + // dated bar, so compact web needs the overlay every bit as much as desktop. + const scrollbar = useWebScrollViewScrollbar(scrollRef, { enabled: isWeb }); + + return ( + + + + + + + + {scrollbar.overlay} + + + + ); +} + +/** Error / first-load / empty / content, in that order of precedence. */ +function ListBody({ + error, + loading, + rowCount, + emptyLabel, + children, +}: { + error: string | null; + loading: boolean; + rowCount: number; + emptyLabel: string; + children: ReactNode; +}): ReactNode { + const { t } = useTranslation(); + if (error) { + return ( + + {error} + + ); + } + if (loading && rowCount === 0) { + return ( + + {t("gitFileHistory.loading")} + + ); + } + if (rowCount === 0) { + return ( + + {emptyLabel} + + ); + } + return children; +} + +function entryToFocus(entry: GitFileHistoryEntry | null): RevisionFocus | null { + if (!entry) { + return null; + } + return { sha: entry.sha, shortSha: entry.shortSha, path: entry.path }; +} + +export const fileHistoryPanelRegistration: PanelRegistration<"fileHistory"> = { + kind: "fileHistory", + component: FileHistoryPanel, + useDescriptor: useFileHistoryPanelDescriptor, + confirmClose() { + return Promise.resolve(true); + }, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.background, + }, + toolbar: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + toolbarSpacer: { + flex: 1, + }, + toolbarButton: { + alignItems: "center", + justifyContent: "center", + width: compactUp(26, 1.5), + height: compactUp(26, 1.5), + borderRadius: theme.borderRadius.sm, + }, + toolbarButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + toolbarButtonActive: { + backgroundColor: theme.colors.surface3, + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: compactFont(theme.fontSize.xs), + }, + scopeChip: { + color: theme.colors.foreground, + fontSize: compactFont(theme.fontSize.xs), + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.sm, + paddingHorizontal: theme.spacing[2], + paddingVertical: 2, + }, + originText: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.xs), + }, + stack: { + flex: 1, + minHeight: 0, + flexDirection: "column", + }, + listPane: { + minHeight: 0, + }, + diffPane: { + minHeight: 0, + }, + detailPane: { + minHeight: 0, + }, + listHost: { + flex: 1, + minHeight: 0, + }, + listScrollHost: { + flex: 1, + minHeight: 0, + position: "relative", + }, + listScroll: { + flex: 1, + }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + mutedText: { + color: theme.colors.foregroundMuted, + fontSize: compactFont(theme.fontSize.sm), + textAlign: "center", + }, + errorText: { + color: theme.colors.destructive, + fontSize: compactFont(theme.fontSize.sm), + textAlign: "center", + }, +})); diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx new file mode 100644 index 000000000..8da17ae1a --- /dev/null +++ b/packages/app/src/panels/file-panel.tsx @@ -0,0 +1,128 @@ +import { Text, View } from "react-native"; +import { FileText } from "@/components/icons/material-icons"; +import invariant from "tiny-invariant"; +import { useTranslation } from "react-i18next"; +import { FileTabPane } from "@/components/file-tab-pane"; +import { + buildEditorBufferKey, + isEditorBufferDirty, + removeEditorBuffer, + useEditorBufferStore, +} from "@/editor/editor-buffer-store"; +import { i18n } from "@/i18n/i18next"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptorContext, PanelRegistration } from "@/panels/panel-registry"; +import { useWorkspaceDirectory, useWorkspaceProjectId } from "@/stores/session-store-hooks"; +import { useProjectLinkSet } from "@/projects/project-links"; +import { resolveEditGate } from "@/projects/cross-project-open"; +import { confirmDialog } from "@/utils/confirm-dialog"; + +const CENTERED_PADDED_STYLE = { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: 16, +} as const; + +function useFilePanelDescriptor( + target: { kind: "file"; path: string; origin?: { workspaceId: string } }, + context: PanelDescriptorContext, +) { + const fileName = target.path.split("/").findLast(Boolean) ?? target.path; + // Out-of-project tabs key their buffer by the OWNING workspace, not the host + // pane's, so the dirty indicator must read the same key (gated-multi-root). + const bufferWorkspaceId = target.origin?.workspaceId ?? context.workspaceId; + const dirty = useEditorBufferStore( + (state) => + state.buffers[ + buildEditorBufferKey({ + serverId: context.serverId, + workspaceId: bufferWorkspaceId, + path: target.path, + }) + ]?.dirty ?? false, + ); + return { + label: dirty ? `● ${fileName}` : fileName, + subtitle: target.path, + titleState: "ready" as const, + icon: FileText, + statusBucket: null, + }; +} + +interface EditorBufferId { + serverId: string; + workspaceId: string; + path: string; +} + +/** + * Closing the tab drops the file's editor buffer; unsaved changes require an + * explicit discard first. Mode switches inside the tab never discard. + */ +async function confirmDiscardEditorBuffer(bufferId: EditorBufferId): Promise { + if (!isEditorBufferDirty(bufferId)) { + removeEditorBuffer(bufferId); + return true; + } + const confirmed = await confirmDialog({ + title: i18n.t("editor.discardDialog.title"), + message: i18n.t("editor.discardDialog.message"), + confirmLabel: i18n.t("editor.discardDialog.confirm"), + cancelLabel: i18n.t("editor.cancel"), + destructive: true, + }); + if (confirmed) { + removeEditorBuffer(bufferId); + } + return confirmed; +} + +function FilePanel() { + const { t } = useTranslation(); + const { serverId, workspaceId, target } = usePaneContext(); + const paneWorkspaceDirectory = useWorkspaceDirectory(serverId, workspaceId); + const paneProjectId = useWorkspaceProjectId(serverId, workspaceId); + const { linkSet } = useProjectLinkSet(serverId); + invariant(target.kind === "file", "FilePanel requires file target"); + // An out-of-project file (gated-multi-root) is served from its OWNING + // workspace: cwd, workspaceId, and editor buffer all resolve against origin, + // so the same daemon file RPCs work unchanged for a linked project's files. + const origin = target.origin; + const effectiveWorkspaceId = origin?.workspaceId ?? workspaceId; + const effectiveRoot = origin?.cwd ?? paneWorkspaceDirectory; + // Computed against the LIVE link set so linking/unlinking projects while the + // tab is open updates whether editing warns. + const editGate = resolveEditGate({ origin, currentProjectId: paneProjectId, linkSet }); + if (!effectiveRoot) { + return ( + + {t("panels.file.directoryMissing")} + + ); + } + return ( + + ); +} + +export const filePanelRegistration: PanelRegistration<"file"> = { + kind: "file", + component: FilePanel, + useDescriptor: useFilePanelDescriptor, + confirmClose(target, context) { + return confirmDiscardEditorBuffer({ + serverId: context.serverId, + // Match the origin-aware buffer key used by the pane (gated-multi-root). + workspaceId: target.origin?.workspaceId ?? context.workspaceId, + path: target.path, + }); + }, +}; diff --git a/packages/app/src/panels/git-log-panel.tsx b/packages/app/src/panels/git-log-panel.tsx new file mode 100644 index 000000000..429cb4f11 --- /dev/null +++ b/packages/app/src/panels/git-log-panel.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + ScrollView, + Text, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import invariant from "tiny-invariant"; +import type { GitOperationLogEntry } from "@otto-code/protocol/messages"; +import { SquareTerminal } from "@/components/icons/material-icons"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; +import { DEFAULT_MONO_FONT_STACK } from "@/styles/theme"; +import { isWeb } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { useAppSettings } from "@/hooks/use-settings"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { buildGitLogKey, useGitLogStore } from "@/git/log-store"; + +const EMPTY_ENTRIES: readonly GitOperationLogEntry[] = []; +const BOTTOM_PIN_THRESHOLD_PX = 40; + +export function gitLogTabTitle( + operation: string, + t: ReturnType["t"], +): string { + if (operation === "commit") { + return t("workspace.git.log.titleCommit"); + } + if (operation === "pull") { + return t("workspace.git.log.titlePull"); + } + if (operation === "push") { + return t("workspace.git.log.titlePush"); + } + return t("workspace.git.log.titleGeneric", { operation }); +} + +function useGitLogPanelDescriptor( + target: { kind: "gitLog"; operation: string }, + _context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + const { t } = useTranslation(); + return { + label: gitLogTabTitle(target.operation, t), + subtitle: t("workspace.git.log.subtitle"), + titleState: "ready", + icon: SquareTerminal, + statusBucket: null, + }; +} + +function GitLogPanel() { + const { serverId, workspaceId, target } = usePaneContext(); + invariant(target.kind === "gitLog", "GitLogPanel requires gitLog target"); + const operation = target.operation; + const { t } = useTranslation(); + const { settings } = useAppSettings(); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + + const entries = useGitLogStore((state) => + cwd + ? (state.entriesByKey[buildGitLogKey({ serverId, cwd, operation })] ?? EMPTY_ENTRIES) + : EMPTY_ENTRIES, + ); + + // Backfill the daemon's buffer once the pane knows its cwd; live appends + // arrive through the session listener feeding the same store. + useEffect(() => { + if (!client || !cwd) { + return; + } + let cancelled = false; + const backfill = async () => { + try { + const payload = await client.checkoutGitGetOperationLog(cwd, operation); + if (!cancelled) { + useGitLogStore + .getState() + .mergeEntries({ serverId, cwd, operation, entries: payload.entries }); + } + } catch { + // Old daemons have no backfill RPC; the pane just starts empty. + } + }; + void backfill(); + return () => { + cancelled = true; + }; + }, [client, cwd, operation, serverId]); + + const scrollRef = useRef(null); + const pinnedToBottomRef = useRef(true); + const isMobile = useIsCompactFormFactor(); + const showDesktopWebScrollbar = isWeb && !isMobile; + const { + onScroll: onScrollbarScroll, + onLayout: onScrollbarLayout, + onContentSizeChange: onScrollbarContentSize, + overlay: scrollbarOverlay, + } = useWebScrollViewScrollbar(scrollRef, { enabled: showDesktopWebScrollbar }); + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + pinnedToBottomRef.current = + contentOffset.y + layoutMeasurement.height >= contentSize.height - BOTTOM_PIN_THRESHOLD_PX; + onScrollbarScroll(event); + }, + [onScrollbarScroll], + ); + const handleContentSizeChange = useCallback( + (width: number, height: number) => { + if (pinnedToBottomRef.current) { + scrollRef.current?.scrollToEnd({ animated: false }); + } + onScrollbarContentSize(width, height); + }, + [onScrollbarContentSize], + ); + + // Empty setting ("") means "use the platform default mono stack" — fall back to + // it explicitly so log lines render monospace like the terminal, rather than + // inheriting the sans interface font. + const monoFontFamily = settings.monoFontFamily.trim() || DEFAULT_MONO_FONT_STACK; + const levelStyles = useMemo(() => { + const lineTextStyle = { + fontSize: settings.codeFontSize, + lineHeight: Math.round(settings.codeFontSize * 1.5), + fontFamily: monoFontFamily, + }; + return { + info: [styles.line, lineTextStyle, styles.lineInfo], + output: [styles.line, lineTextStyle], + error: [styles.line, lineTextStyle, styles.lineError], + }; + }, [monoFontFamily, settings.codeFontSize]); + + if (entries.length === 0) { + return ( + + {t("workspace.git.log.empty")} + + ); + } + + return ( + + + {entries.map((entry) => ( + + {entry.text} + + ))} + + {scrollbarOverlay} + + ); +} + +export const gitLogPanelRegistration: PanelRegistration<"gitLog"> = { + kind: "gitLog", + component: GitLogPanel, + useDescriptor: useGitLogPanelDescriptor, + confirmClose() { + return Promise.resolve(true); + }, +}; + +const styles = StyleSheet.create((theme) => ({ + scrollHost: { + flex: 1, + minHeight: 0, + position: "relative", + backgroundColor: theme.colors.background, + }, + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + content: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.background, + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + line: { + color: theme.colors.foreground, + }, + lineInfo: { + color: theme.colors.foregroundMuted, + }, + lineError: { + color: theme.colors.palette.red[300], + }, +})); diff --git a/packages/app/src/panels/orchestration-graph-panel-registration.tsx b/packages/app/src/panels/orchestration-graph-panel-registration.tsx new file mode 100644 index 000000000..d4ed30ef4 --- /dev/null +++ b/packages/app/src/panels/orchestration-graph-panel-registration.tsx @@ -0,0 +1,63 @@ +import { lazy, Suspense } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Schema } from "@/components/icons/material-icons"; +import { useOrchestrationGraphs } from "@/hooks/use-orchestration-graphs"; +import type { + PanelDescriptor, + PanelDescriptorContext, + PanelRegistration, +} from "@/panels/panel-registry"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; + +// The graph designer panel (projects/orchestration-graphs) behind a React.lazy +// boundary, like the Visualizer: register-panels imports this thin module, so +// the vendored Drawflow bundle + canvas wrapper stay out of the startup graph +// and load only when a designer tab actually renders. Metro resolves the +// heavy web implementation (.web.tsx) or the native placeholder (.tsx). +const LazyOrchestrationGraphPanel = lazy(() => + import("@/orchestration-graph/orchestration-graph-panel").then((mod) => ({ + default: mod.OrchestrationGraphPanel, + })), +); + +function useOrchestrationGraphPanelDescriptor( + target: Extract, + context: PanelDescriptorContext, +): PanelDescriptor { + const graphsQuery = useOrchestrationGraphs(context.serverId); + const graph = (graphsQuery.data ?? []).find((candidate) => candidate.id === target.graphId); + return { + label: graph?.name ?? "Graph", + subtitle: "Orchestration graph", + titleState: "ready", + icon: Schema, + statusBucket: null, + }; +} + +function OrchestrationGraphFallback() { + return ; +} +const ORCHESTRATION_GRAPH_FALLBACK = ; + +function OrchestrationGraphPanelHost() { + return ( + + + + ); +} + +export const orchestrationGraphPanelRegistration: PanelRegistration<"orchestrationGraph"> = { + kind: "orchestrationGraph", + component: OrchestrationGraphPanelHost, + useDescriptor: useOrchestrationGraphPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + fallback: { + flex: 1, + backgroundColor: theme.colors.background, + }, +})); diff --git a/packages/app/src/panels/pane-context.test.ts b/packages/app/src/panels/pane-context.test.ts new file mode 100644 index 000000000..cfd601ff4 --- /dev/null +++ b/packages/app/src/panels/pane-context.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { createPaneFocusContextValue } from "@/panels/pane-context"; + +describe("createPaneFocusContextValue", () => { + it("derives interactivity from both workspace and pane focus", () => { + expect( + createPaneFocusContextValue({ + isWorkspaceFocused: true, + isPaneFocused: true, + }), + ).toEqual({ + isWorkspaceFocused: true, + isPaneFocused: true, + isInteractive: true, + isVisible: true, + focusPane: expect.any(Function), + }); + expect( + createPaneFocusContextValue({ + isWorkspaceFocused: false, + isPaneFocused: true, + }), + ).toEqual({ + isWorkspaceFocused: false, + isPaneFocused: true, + isInteractive: false, + isVisible: false, + focusPane: expect.any(Function), + }); + }); + + it("keeps a companion pane visible while unfocused when isVisible is given", () => { + // A split pane that is on screen (frontmost tab, workspace focused) but not + // the focused pane: not interactive, still visible. + expect( + createPaneFocusContextValue({ + isWorkspaceFocused: true, + isPaneFocused: false, + isVisible: true, + }), + ).toMatchObject({ + isInteractive: false, + isVisible: true, + }); + }); + + it("defaults isVisible to the focused value when omitted", () => { + expect( + createPaneFocusContextValue({ + isWorkspaceFocused: true, + isPaneFocused: false, + }), + ).toMatchObject({ isVisible: false }); + }); +}); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx new file mode 100644 index 000000000..53a694a3c --- /dev/null +++ b/packages/app/src/panels/pane-context.tsx @@ -0,0 +1,85 @@ +import React, { createContext, useContext, type ReactNode } from "react"; +import invariant from "tiny-invariant"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; + +export interface PaneContextValue { + serverId: string; + workspaceId: string; + tabId: string; + target: WorkspaceTabTarget; + openTab: (target: WorkspaceTabTarget) => void; + closeCurrentTab: () => void; + retargetCurrentTab: (target: WorkspaceTabTarget) => void; + openFileInWorkspace: (request: WorkspaceFileOpenRequest) => void; + openImportSheet: () => void; +} + +export interface PaneFocusContextValue { + isWorkspaceFocused: boolean; + isPaneFocused: boolean; + isInteractive: boolean; + /** The pane's content is actually on screen and rendering: the workspace + * route is focused AND this tab is the frontmost tab in its pane. Unlike + * `isInteractive`/`isPaneFocused`, this does NOT require the pane to hold + * focus — a companion view in an unfocused split (e.g. the Visualizer next + * to the chat you're typing in) is visible but not focused. Consumers that + * should keep running whenever they're watchable (not just when clicked + * into) gate on this. */ + isVisible: boolean; + focusPane: () => void; +} + +const PaneContext = createContext(null); +const PaneFocusContext = createContext(null); +const noopFocusPane = () => {}; + +export function createPaneFocusContextValue(input: { + isWorkspaceFocused: boolean; + isPaneFocused: boolean; + /** Whether the pane's content is on screen (see `isVisible` on + * PaneFocusContextValue). Optional: callers that don't distinguish + * visibility from focus fall back to the focused-and-on-workspace value. */ + isVisible?: boolean; + onFocusPane?: () => void; +}): PaneFocusContextValue { + return { + isWorkspaceFocused: input.isWorkspaceFocused, + isPaneFocused: input.isPaneFocused, + isInteractive: input.isWorkspaceFocused && input.isPaneFocused, + isVisible: input.isVisible ?? (input.isWorkspaceFocused && input.isPaneFocused), + focusPane: input.onFocusPane ?? noopFocusPane, + }; +} + +export function PaneProvider({ + value, + children, +}: { + value: PaneContextValue; + children: ReactNode; +}) { + return {children}; +} + +export function PaneFocusProvider({ + value, + children, +}: { + value: PaneFocusContextValue; + children: ReactNode; +}) { + return {children}; +} + +export function usePaneContext(): PaneContextValue { + const value = useContext(PaneContext); + invariant(value, "PaneContext is required"); + return value; +} + +export function usePaneFocus(): PaneFocusContextValue { + const value = useContext(PaneFocusContext); + invariant(value, "PaneFocusContext is required"); + return value; +} diff --git a/packages/app/src/panels/panel-registry.ts b/packages/app/src/panels/panel-registry.ts new file mode 100644 index 000000000..d9581747c --- /dev/null +++ b/packages/app/src/panels/panel-registry.ts @@ -0,0 +1,62 @@ +import type { ComponentType } from "react"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; + +export interface PanelIconProps { + size: number; + color: string; +} + +export interface PanelDescriptor { + label: string; + subtitle: string; + titleState: "ready" | "loading"; + icon: ComponentType; + statusBucket: SidebarStateBucket | null; + /** + * Personality spinner colors for this tab's busy loader, when the agent was + * spawned from a personality. Absent/null ⇒ the tab uses the theme spinner. + * Only the agent panel sets this; other panels leave it undefined. + */ + personalitySpinner?: { glowA: string; glowB: string } | null; + /** + * Provider id for the tab glyph. Lets the non-loading agent tab fill its + * provider icon with the personality gradient (paired with personalitySpinner). + * Only the agent panel sets this. + */ + provider?: string; +} + +export interface PanelDescriptorContext { + serverId: string; + workspaceId: string; +} + +export interface PanelRegistration< + K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"], +> { + kind: K; + component: ComponentType; + useDescriptor( + target: Extract, + context: PanelDescriptorContext, + ): PanelDescriptor; + confirmClose?( + target: Extract, + context: PanelDescriptorContext, + ): Promise; +} + +const panelRegistry = new Map(); + +export function registerPanel( + registration: PanelRegistration, +): void { + panelRegistry.set(registration.kind, registration as unknown as PanelRegistration); +} + +export function getPanelRegistration( + kind: WorkspaceTabTarget["kind"], +): PanelRegistration | undefined { + return panelRegistry.get(kind); +} diff --git a/packages/app/src/panels/refine-panel.tsx b/packages/app/src/panels/refine-panel.tsx new file mode 100644 index 000000000..9bf36ac14 --- /dev/null +++ b/packages/app/src/panels/refine-panel.tsx @@ -0,0 +1,1237 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { + Check, + CheckSquare, + Compress, + RotateCw, + WandStars, + X, +} from "@/components/icons/material-icons"; +import { DiffViewer } from "@/components/diff-viewer"; +import { TreeChevron } from "@/components/tree-primitives"; +import { Button } from "@/components/ui/button"; +import { PANE_TOOLBAR_HEIGHT } from "@/components/ui/control-geometry"; +import { Switch } from "@/components/ui/switch"; +import { TextAreaScrollFrame } from "@/components/ui/text-area"; +import { ToolbarIconButton } from "@/components/ui/toolbar-icon-button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { + CodeResultExpandToggle, + CodeResultGroupHeader, + splitPath, + useCollapsedGroups, +} from "@/editor/code-results/result-rows"; +import { i18n } from "@/i18n/i18next"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useRefineFeature } from "@/refine/use-refine-feature"; +import { + useRefineSession, + type RefinePhase, + type RefineSession, + type RefineWriteOutcome, +} from "@/refine/use-refine-session"; +import { + REFINE_PRESETS, + findRefinePreset, + isRefineInstructionValid, + refineJobFor, + refinePresetDescription, + refinePresetLabel, + type RefineJobKind, + type RefinePreset, +} from "@/refine/refine-presets"; +import { buildRefineWorkingSet } from "@/refine/refine-working-set"; +import type { RefineFileProposal, RefineSetFile, RefineSetStats } from "@/refine/refine-set"; +import type { RefineHunk } from "@/refine/hunks"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { compactFont, type Theme } from "@/styles/theme"; + +/** + * An AI rewrite as an auditable job — the document half of what the rename tab + * does for symbols. + * + * The shape is the same on purpose, down to the chrome: one toolbar at the file + * editor's exact height, icon buttons with their labels in tooltips, and a + * single accent-tinted action slot whose meaning follows the phase. These tabs + * open beside the editor in a split, so a bar built out of text buttons stands + * taller than the one next to it and reads as a mistake in the split rather + * than as a different panel. + * + * Impact first, action second. Nothing is written until Accept, and Accept is a + * conditional write per file against the identity the session pinned, so a file + * that changed underneath comes back as a refusal rather than an overwrite. + * + * Two things are different from rename, and both are why this is not a dialog: + * + * - The job is **iterative**: the answer is a proposal you argue with. The + * instruction bar stays live under the toolbar, because refining again is the + * main gesture, not an escape hatch, and every round re-diffs against the + * same pinned bases so five rounds of "tighten it further" cannot hide drift. + * - The job **spans files**. The working-set strip is the blast radius, made + * editable: a file marked read-only goes to the model as context and can + * never come back as an edit. + */ + +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedCheck = withUnistyles(Check); +const ThemedX = withUnistyles(X); +const ThemedCheckSquare = withUnistyles(CheckSquare); +// The field, not the `TextArea` wrapper. `withUnistyles` applies a wrapped +// component's style through a `.hash > *` child selector, so wrapping a +// composite lands the style on its outer frame and the real `2 + , + ); + + fireEvent.click(getByLabelText("Add review comment")); + expect(onStartComment).toHaveBeenCalledWith(reviewTarget); + expect(pressablePropsByLabel.get("Add review comment")?.hitSlop).toBe(SMALL_ACTION_HIT_SLOP); + }); + + it("keeps the line number visible and only floats the plus for line hover", () => { + const reviewTarget = target(); + const { container, queryByText, rerender } = render( + + 2 + , + ); + + expect(queryByText("2")).toBeTruthy(); + expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + + rerender( + + 2 + , + ); + + expect(queryByText("2")).toBeTruthy(); + expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + + rerender( + + 2 + , + ); + + expect(queryByText("2")).toBeTruthy(); + expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + + rerender( + + 2 + , + ); + + expect(queryByText("2")).toBeTruthy(); + expect(container.querySelector("[data-icon='Plus']")).toBeTruthy(); + }); +}); + +describe("InlineReviewEditor", () => { + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + window.matchMedia = originalMatchMedia; + cleanup(); + vi.clearAllMocks(); + }); + + it("saves trimmed bodies and cancels without saving", () => { + const onCancel = vi.fn(); + const onSave = vi.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.change(getByTestId("editor-input"), { target: { value: " updated comment " } }); + fireEvent.click(getByTestId("editor-save")); + expect(onSave).toHaveBeenCalledWith("updated comment"); + + fireEvent.click(getByTestId("editor-cancel")); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("handles Escape cancel and Mod+Enter save from the focused textarea", () => { + const onCancel = vi.fn(); + const onSave = vi.fn(); + const { getByTestId } = render( + , + ); + const input = getByTestId("editor-input"); + + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onCancel).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + expect(onSave).toHaveBeenCalledWith("ready"); + }); + + it("shows shared shortcut hints while focused on a fine-pointer screen", () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const { getByTestId, getByText, queryByText } = render( + , + ); + const input = getByTestId("editor-input"); + + expect(getByText("Esc")).toBeTruthy(); + expect(getByText(/(?:⌘⏎|Ctrl\+⏎)/)).toBeTruthy(); + + fireEvent.blur(input); + expect(queryByText("Esc")).toBeNull(); + }); +}); + +describe("InlineReviewThread", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("exposes edit and delete actions for existing comments", () => { + const reviewTarget = target(); + const draftComment = comment(); + const actions = buildReviewActions({ + commentsByTarget: groupInlineReviewCommentsByTarget([draftComment]), + }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByText("Please simplify this.")).toBeTruthy(); + fireEvent.click(getByTestId("review-comment-edit-comment-1")); + expect(actions.onEditComment).toHaveBeenCalledWith(reviewTarget, draftComment); + fireEvent.click(getByTestId("review-comment-delete-comment-1")); + expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1"); + }); +}); diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx new file mode 100644 index 000000000..835db140f --- /dev/null +++ b/packages/app/src/review/surface.tsx @@ -0,0 +1,763 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Pencil, Plus, Trash2 } from "@/components/icons/material-icons"; +import { + Pressable, + type PressableStateCallbackType, + Text, + TextInput, + type TextStyle, + View, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Shortcut } from "@/components/ui/shortcut"; +import { isWeb } from "@/constants/platform"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import type { Theme } from "@/styles/theme"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import { useWorkspaceFocusRestoration } from "@/workspace/focus"; +import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; +import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; + +type PressableState = PressableStateCallbackType & { hovered?: boolean }; +type WebTextInputRef = TextInput & { + getNativeElement?: () => unknown; + getNativeRef?: () => unknown; +}; + +function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp { + return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered]; +} + +function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): StyleProp { + return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered]; +} + +function getWebTextInputElement(input: TextInput | null): HTMLElement | null { + if (!isWeb || typeof HTMLElement === "undefined" || !input) { + return null; + } + const webInput = input as WebTextInputRef; + const element = webInput.getNativeElement?.() ?? webInput.getNativeRef?.() ?? input; + return element instanceof HTMLElement ? element : null; +} + +export const INLINE_REVIEW_COMMENT_HEIGHT = 72; +export const INLINE_REVIEW_EDITOR_HEIGHT = 132; +const INLINE_REVIEW_GAP = 6; +export const SMALL_ACTION_HIT_SLOP = 8; +const GUTTER_ACTION_ICON_SIZE = 22; +const GUTTER_ACTION_GLYPH_SIZE = 16; +const REVIEW_CANCEL_SHORTCUT_KEYS: ShortcutKey[] = ["Esc"]; +const REVIEW_SAVE_SHORTCUT_KEYS: ShortcutKey[] = ["mod", "Enter"]; +const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const destructiveIconColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); +const accentForegroundIconColorMapping = (theme: Theme) => ({ + color: theme.colors.accentForeground, +}); +const ThemedPencil = withUnistyles(Pencil); +const ThemedPlus = withUnistyles(Plus); +const ThemedTrash2 = withUnistyles(Trash2); + +export interface InlineReviewEditorState { + target: ReviewableDiffTarget; + commentId: string | null; + body: string; +} + +export interface InlineReviewActions { + commentsByTarget: ReadonlyMap; + editor: InlineReviewEditorState | null; + onStartComment: (target: ReviewableDiffTarget) => void; + onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; + onCancelEditor: () => void; + onSaveEditor: (body: string) => void; + onDeleteComment: (id: string) => void; +} + +export function groupInlineReviewCommentsByTarget( + comments: readonly ReviewDraftComment[], +): Map { + const grouped = new Map(); + for (const comment of comments) { + const key = buildReviewableDiffTargetKey(comment); + grouped.set(key, [...(grouped.get(key) ?? []), comment]); + } + return grouped; +} + +export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions { + const reviewComments = useReviewDraftComments(input.reviewDraftKey); + const commentsByTarget = useMemo( + () => groupInlineReviewCommentsByTarget(reviewComments), + [reviewComments], + ); + const [editor, setEditor] = useState(null); + const addComment = useReviewDraftStore((state) => state.addComment); + const updateComment = useReviewDraftStore((state) => state.updateComment); + const deleteComment = useReviewDraftStore((state) => state.deleteComment); + + useEffect(() => { + setEditor(null); + }, [input.reviewDraftKey]); + + const handleStartComment = useCallback((target: ReviewableDiffTarget) => { + setEditor({ target, commentId: null, body: "" }); + }, []); + + const handleEditComment = useCallback( + (target: ReviewableDiffTarget, comment: ReviewDraftComment) => { + setEditor({ target, commentId: comment.id, body: comment.body }); + }, + [], + ); + + const handleCancelEditor = useCallback(() => { + setEditor(null); + }, []); + + const handleSaveEditor = useCallback( + (body: string) => { + const trimmedBody = body.trim(); + if (!editor || trimmedBody.length === 0) { + return; + } + + if (editor.commentId) { + updateComment({ + key: input.reviewDraftKey, + id: editor.commentId, + updates: { body: trimmedBody }, + }); + } else { + addComment({ + key: input.reviewDraftKey, + comment: { + filePath: editor.target.filePath, + side: editor.target.side, + lineNumber: editor.target.lineNumber, + body: trimmedBody, + }, + }); + } + setEditor(null); + }, + [addComment, editor, input.reviewDraftKey, updateComment], + ); + + const handleDeleteComment = useCallback( + (id: string) => { + deleteComment({ key: input.reviewDraftKey, id }); + setEditor((current) => (current?.commentId === id ? null : current)); + }, + [deleteComment, input.reviewDraftKey], + ); + + return useMemo( + () => ({ + commentsByTarget, + editor, + onStartComment: handleStartComment, + onEditComment: handleEditComment, + onCancelEditor: handleCancelEditor, + onSaveEditor: handleSaveEditor, + onDeleteComment: handleDeleteComment, + }), + [ + commentsByTarget, + editor, + handleCancelEditor, + handleDeleteComment, + handleEditComment, + handleSaveEditor, + handleStartComment, + ], + ); +} + +export function isInlineReviewEditorForTarget( + editor: InlineReviewEditorState | null, + target: ReviewableDiffTarget | null | undefined, +): boolean { + return Boolean( + editor && + target && + buildReviewableDiffTargetKey(editor.target) === buildReviewableDiffTargetKey(target), + ); +} + +export function getInlineReviewThreadState(input: { + reviewTarget: ReviewableDiffTarget | null | undefined; + reviewActions?: InlineReviewActions; +}): { + comments: ReviewDraftComment[]; + hasEditor: boolean; + editingCommentId: string | null; + height: number; +} | null { + const { reviewTarget, reviewActions } = input; + if (!reviewTarget || !reviewActions) { + return null; + } + + const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? []; + const editorForTarget = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget) + ? reviewActions.editor + : null; + const hasEditor = editorForTarget !== null; + const editingCommentId = editorForTarget?.commentId ?? null; + const editingExisting = + editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId); + + const visibleCommentCount = editingExisting ? comments.length - 1 : comments.length; + const editorCount = hasEditor ? 1 : 0; + const visibleBlockCount = visibleCommentCount + editorCount; + if (visibleBlockCount === 0) { + return null; + } + + const height = + visibleCommentCount * INLINE_REVIEW_COMMENT_HEIGHT + + editorCount * INLINE_REVIEW_EDITOR_HEIGHT + + Math.max(0, visibleBlockCount - 1) * INLINE_REVIEW_GAP; + + return { comments, hasEditor, editingCommentId, height }; +} + +export function getSplitInlineReviewThreadState(input: { + left: ReviewableDiffTarget | null | undefined; + right: ReviewableDiffTarget | null | undefined; + reviewActions?: InlineReviewActions; +}): { + left: ReturnType; + right: ReturnType; + height: number; +} | null { + const left = getInlineReviewThreadState({ + reviewTarget: input.left, + reviewActions: input.reviewActions, + }); + const right = getInlineReviewThreadState({ + reviewTarget: input.right, + reviewActions: input.reviewActions, + }); + const height = Math.max(left?.height ?? 0, right?.height ?? 0); + if (height === 0) { + return null; + } + return { left, right, height }; +} + +export function InlineReviewGutterCell({ + children, + reviewTarget, + comments, + isLineHovered = false, + lineHeight, + onStartComment, + style, + actionTestID, +}: { + children: ReactNode; + reviewTarget: ReviewableDiffTarget | null | undefined; + comments: readonly ReviewDraftComment[]; + isEditorOpen: boolean; + isLineHovered?: boolean; + lineHeight?: number; + onStartComment: (target: ReviewableDiffTarget) => void; + style?: StyleProp; + actionTestID?: string; +}) { + const { t } = useTranslation(); + const canComment = Boolean(reviewTarget); + const hasComments = comments.length > 0; + const [isGutterHovered, setIsGutterHovered] = useState(false); + const [isPressed, setIsPressed] = useState(false); + const [isDismissedAfterPress, setIsDismissedAfterPress] = useState(false); + const isInteractionActive = isGutterHovered || isLineHovered || isPressed; + const showAction = canComment && isInteractionActive && !isDismissedAfterPress; + + const handlePress = useCallback(() => { + if (reviewTarget) { + setIsDismissedAfterPress(true); + onStartComment(reviewTarget); + } + }, [reviewTarget, onStartComment]); + + const handleHoverIn = useCallback(() => { + setIsGutterHovered(true); + }, []); + + const handleHoverOut = useCallback(() => { + setIsGutterHovered(false); + }, []); + + const handlePressIn = useCallback(() => { + setIsPressed(true); + }, []); + + const handlePressOut = useCallback(() => { + setIsPressed(false); + }, []); + + useEffect(() => { + if (!isInteractionActive) { + setIsDismissedAfterPress(false); + } + }, [isInteractionActive]); + + const pressableStyle = useCallback((): StyleProp => style, [style]); + const lineHeightStyle = useMemo>( + () => + lineHeight !== undefined + ? inlineUnistylesStyle({ height: lineHeight, minHeight: lineHeight }) + : null, + [lineHeight], + ); + + const labelStyle = useMemo>( + () => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive], + [hasComments, lineHeightStyle], + ); + const innerStyle = useMemo>( + () => [styles.gutterInner, lineHeightStyle], + [lineHeightStyle], + ); + // Clamp to the row's own height so the button never overflows into a + // neighboring row — react-native-web gives every row its own stacking + // context, so an overflowing button can end up painted over by whichever + // adjacent row happens to come later in paint order. + const actionIconSize = + lineHeight !== undefined + ? Math.min(GUTTER_ACTION_ICON_SIZE, lineHeight) + : GUTTER_ACTION_ICON_SIZE; + const actionIconGlyphSize = Math.min(GUTTER_ACTION_GLYPH_SIZE, Math.max(actionIconSize - 6, 10)); + const actionIconStyle = useMemo>( + () => [ + styles.gutterActionIcon, + lineHeight !== undefined && + inlineUnistylesStyle({ + width: actionIconSize, + height: actionIconSize, + top: Math.floor((lineHeight - actionIconSize) / 2), + }), + ], + [actionIconSize, lineHeight], + ); + + return ( + + + + {children} + {showAction ? ( + + + + ) : null} + + + + ); +} + +export function InlineReviewThread({ + reviewTarget, + reviewActions, + height, + viewportWidth, + pinToViewport = false, + testID, +}: { + reviewTarget: ReviewableDiffTarget; + reviewActions: InlineReviewActions; + height: number; + viewportWidth?: number; + pinToViewport?: boolean; + testID?: string; +}) { + const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? []; + const editor = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget) + ? reviewActions.editor + : null; + const editingCommentId = editor?.commentId ?? null; + const editingExisting = + editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId); + + const editorElement = editor ? ( + + ) : null; + + const containerStyle = useMemo>( + () => [ + styles.threadContainer, + getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }), + inlineUnistylesStyle({ minHeight: height }), + ], + [viewportWidth, pinToViewport, height], + ); + + return ( + + {comments.map((comment) => { + if (comment.id === editingCommentId) { + return {editorElement}; + } + return ( + + ); + })} + {editor && !editingExisting ? editorElement : null} + + ); +} + +function CommentRow({ + comment, + reviewTarget, + onEditComment, + onDeleteComment, +}: { + comment: ReviewDraftComment; + reviewTarget: ReviewableDiffTarget; + onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; + onDeleteComment: (id: string) => void; +}) { + const { t } = useTranslation(); + const handleEdit = useCallback( + () => onEditComment(reviewTarget, comment), + [onEditComment, reviewTarget, comment], + ); + + const handleDelete = useCallback( + () => onDeleteComment(comment.id), + [onDeleteComment, comment.id], + ); + + return ( + + + {comment.body} + + + + + + + + + + + ); +} + +export function getInlineReviewThreadViewportStyle({ + viewportWidth, + pinToViewport, +}: { + viewportWidth?: number; + pinToViewport: boolean; +}): StyleProp { + const widthStyle = + viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null; + if (!pinToViewport || !isWeb) { + return widthStyle; + } + const stickyStyle = { position: "sticky", left: 0 } as unknown as ViewStyle; + return [stickyStyle, widthStyle]; +} + +export function InlineReviewEditor({ + initialBody, + onCancel, + onSave, + testID, +}: { + initialBody: string; + onCancel: () => void; + onSave: (body: string) => void; + testID?: string; +}) { + const { t } = useTranslation(); + const inputRef = useRef(null); + const focus = useWorkspaceFocusRestoration(); + const canShowKeyboardHints = useHasFinePointer(); + const [body, setBody] = useState(initialBody); + const [isFocused, setIsFocused] = useState(false); + const trimmedBody = body.trim(); + const canSave = trimmedBody.length > 0; + const showKeyboardHints = isFocused && canShowKeyboardHints; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleFocus = useCallback(() => { + focus.unfocus(); + setIsFocused(true); + }, [focus]); + const handleBlur = useCallback(() => { + setIsFocused(false); + focus.restore(); + }, [focus]); + const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]); + const cancelShortcut = useMemo( + () => (showKeyboardHints ? : null), + [showKeyboardHints], + ); + const saveShortcut = useMemo( + () => (showKeyboardHints ? : null), + [showKeyboardHints], + ); + + useEffect(() => { + const element = getWebTextInputElement(inputRef.current); + if (!element) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + return; + } + + if (event.key !== "Enter" || event.shiftKey) { + return; + } + if (!event.metaKey && !event.ctrlKey) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (!canSave) { + return; + } + handleSave(); + }; + + element.addEventListener("keydown", handleKeyDown); + return () => { + element.removeEventListener("keydown", handleKeyDown); + }; + }, [canSave, handleSave, onCancel]); + + const inputStyle = useMemo>( + () => [styles.editorInput, isFocused && styles.editorInputFocused], + [isFocused], + ); + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + gutterInner: { + minHeight: theme.lineHeight.diff, + alignItems: "stretch", + justifyContent: "flex-start", + overflow: "visible", + }, + gutterLabel: { + width: "100%", + minWidth: 0, + height: theme.lineHeight.diff, + alignItems: "stretch", + justifyContent: "flex-start", + position: "relative", + overflow: "visible", + }, + gutterLabelActive: { + backgroundColor: theme.colors.surface2, + }, + gutterActionIcon: { + position: "absolute", + right: -10, + top: Math.floor((theme.lineHeight.diff - GUTTER_ACTION_ICON_SIZE) / 2), + width: GUTTER_ACTION_ICON_SIZE, + height: GUTTER_ACTION_ICON_SIZE, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.accent, + zIndex: 10, + elevation: 10, + }, + placeholderColor: { + color: theme.colors.foregroundMuted, + }, + threadContainer: { + flex: 1, + minWidth: 0, + gap: INLINE_REVIEW_GAP, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + }, + commentBlock: { + backgroundColor: theme.colors.surface2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + commentBody: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, + commentActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 0, + }, + iconButton: { + width: 26, + height: 26, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.full, + ...(isWeb + ? { + transitionProperty: "background-color", + transitionDuration: "120ms", + transitionTimingFunction: "ease-in-out", + } + : {}), + }, + iconButtonHovered: { + backgroundColor: theme.colors.surfaceHover, + }, + iconButtonDestructiveHovered: { + backgroundColor: theme.colors.surfaceHover, + }, + editorBlock: { + minHeight: INLINE_REVIEW_EDITOR_HEIGHT, + backgroundColor: theme.colors.surface2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], + gap: theme.spacing[3], + }, + editorInput: { + flex: 1, + minHeight: 0, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + textAlignVertical: "top", + ...(isWeb + ? { + outlineWidth: 0, + outlineColor: "transparent", + } + : {}), + }, + editorInputFocused: { + borderColor: theme.colors.accent, + }, + editorActions: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/runtime/activity/index.ts b/packages/app/src/runtime/activity/index.ts new file mode 100644 index 000000000..f740e744d --- /dev/null +++ b/packages/app/src/runtime/activity/index.ts @@ -0,0 +1,9 @@ +export type { + ActivityFlushHandle, + AgentLastActivityCoalescer, + AgentLastActivityCommitter, + AgentLastActivityUpdates, +} from "./types"; + +export { scheduleAgentLastActivityFlush } from "./last-activity-scheduler"; +export { createAgentLastActivityCoalescer } from "./last-activity-coalescer"; diff --git a/packages/app/src/runtime/activity/last-activity-coalescer.ts b/packages/app/src/runtime/activity/last-activity-coalescer.ts new file mode 100644 index 000000000..fc7e30a73 --- /dev/null +++ b/packages/app/src/runtime/activity/last-activity-coalescer.ts @@ -0,0 +1,67 @@ +import { scheduleAgentLastActivityFlush } from "./last-activity-scheduler"; +import type { + ActivityFlushHandle, + AgentLastActivityCoalescer, + AgentLastActivityCommitter, +} from "./types"; + +export function createAgentLastActivityCoalescer(): AgentLastActivityCoalescer { + let pending = new Map(); + let scheduledFlush: ActivityFlushHandle | null = null; + let committer: AgentLastActivityCommitter | null = null; + + const cancelScheduledFlush = () => { + if (!scheduledFlush) { + return; + } + scheduledFlush.cancel(); + scheduledFlush = null; + }; + + const flushNow = () => { + cancelScheduledFlush(); + if (pending.size === 0) { + return; + } + const updates = pending; + pending = new Map(); + committer?.(updates); + }; + + const scheduleFlush = () => { + if (scheduledFlush) { + return; + } + scheduledFlush = scheduleAgentLastActivityFlush(() => { + scheduledFlush = null; + flushNow(); + }); + }; + + return { + setCommitter(nextCommitter) { + committer = nextCommitter; + }, + + enqueue(agentId, timestamp) { + const current = pending.get(agentId); + if (current && current.getTime() >= timestamp.getTime()) { + return; + } + pending.set(agentId, timestamp); + scheduleFlush(); + }, + + flushNow, + + deletePending(agentId) { + pending.delete(agentId); + }, + + dispose() { + cancelScheduledFlush(); + pending.clear(); + committer = null; + }, + }; +} diff --git a/packages/app/src/runtime/activity/last-activity-scheduler.ts b/packages/app/src/runtime/activity/last-activity-scheduler.ts new file mode 100644 index 000000000..1e4ac682a --- /dev/null +++ b/packages/app/src/runtime/activity/last-activity-scheduler.ts @@ -0,0 +1,26 @@ +import type { ActivityFlushHandle } from "./types"; + +const DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS = 16; + +export function scheduleAgentLastActivityFlush( + callback: () => void, + fallbackIntervalMs = DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS, +): ActivityFlushHandle { + if (typeof requestAnimationFrame === "function") { + const handle = requestAnimationFrame(callback); + return { + cancel: () => { + if (typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(handle); + } + }, + }; + } + + const handle = setTimeout(callback, fallbackIntervalMs); + return { + cancel: () => { + clearTimeout(handle); + }, + }; +} diff --git a/packages/app/src/runtime/activity/types.ts b/packages/app/src/runtime/activity/types.ts new file mode 100644 index 000000000..805b25b92 --- /dev/null +++ b/packages/app/src/runtime/activity/types.ts @@ -0,0 +1,15 @@ +export type AgentLastActivityUpdates = Map; + +export type AgentLastActivityCommitter = (updates: AgentLastActivityUpdates) => void; + +export interface ActivityFlushHandle { + cancel: () => void; +} + +export interface AgentLastActivityCoalescer { + setCommitter: (committer: AgentLastActivityCommitter | null) => void; + enqueue: (agentId: string, timestamp: Date) => void; + flushNow: () => void; + deletePending: (agentId: string) => void; + dispose: () => void; +} diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts new file mode 100644 index 000000000..ceafedaa1 --- /dev/null +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, vi } from "vitest"; +import { DaemonStartService, upsertDesktopDaemonConnection } from "./daemon-start-service"; +import type { HostRuntimeStore } from "./host-runtime"; +import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; + +interface RecordedUpsert { + listenAddress: string; + serverId: string; + hostname: string | null; +} + +function createFakeStore(): { + store: Pick; + upserts: RecordedUpsert[]; +} { + const upserts: RecordedUpsert[] = []; + const store = { + upsertConnectionFromListen: async (input: RecordedUpsert) => { + upserts.push(input); + return {} as Awaited>; + }, + }; + return { store, upserts }; +} + +function makeStatus(overrides: Partial = {}): DesktopDaemonStatus { + return { + serverId: "srv_desktop", + status: "running", + listen: "127.0.0.1:6868", + hostname: "desktop", + pid: 1234, + home: "/home", + version: "0.0.0", + desktopManaged: true, + error: null, + ...overrides, + }; +} + +describe("DaemonStartService", () => { + it("upserts the connection on a successful daemon start", async () => { + const fake = createFakeStore(); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => makeStatus(), + }); + + const result = await service.start(); + + expect(result).toEqual({ ok: true }); + expect(fake.upserts).toEqual([ + { listenAddress: "127.0.0.1:6868", serverId: "srv_desktop", hostname: "desktop" }, + ]); + expect(service.getLastError()).toBeNull(); + expect(service.isRunning()).toBe(false); + }); + + it("reports lastError after a missing listen address and clears running state when done", async () => { + const fake = createFakeStore(); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => makeStatus({ listen: null }), + }); + + const result = await service.start(); + + expect(result).toEqual({ + ok: false, + error: "Desktop daemon did not return a listen address.", + }); + expect(service.getLastError()).toBe("Desktop daemon did not return a listen address."); + expect(service.isRunning()).toBe(false); + expect(fake.upserts).toEqual([]); + }); + + it("reports lastError when the daemon does not return a server id", async () => { + const fake = createFakeStore(); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => makeStatus({ serverId: "" }), + }); + + const result = await service.start(); + + expect(result).toEqual({ ok: false, error: "Desktop daemon did not return a server id." }); + expect(service.getLastError()).toBe("Desktop daemon did not return a server id."); + expect(fake.upserts).toEqual([]); + }); + + it("reports lastError when the listen address is unsupported", async () => { + const fake = createFakeStore(); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => makeStatus({ listen: "???" }), + }); + + const result = await service.start(); + + expect(result.ok).toBe(false); + expect(service.getLastError()).toContain("unsupported listen address"); + expect(fake.upserts).toEqual([]); + }); + + it("reports lastError when the underlying start call throws", async () => { + const fake = createFakeStore(); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => { + throw new Error("ipc broke"); + }, + }); + + const result = await service.start(); + + expect(result).toEqual({ ok: false, error: "ipc broke" }); + expect(service.getLastError()).toBe("ipc broke"); + }); + + it("clears lastError on retry entry and reports null after subsequent success", async () => { + const fake = createFakeStore(); + const startMock = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("ipc broke")) + .mockResolvedValueOnce(makeStatus()); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: () => startMock(), + }); + + const failure = await service.start(); + expect(failure.ok).toBe(false); + expect(service.getLastError()).toBe("ipc broke"); + + const success = await service.start(); + expect(success).toEqual({ ok: true }); + expect(service.getLastError()).toBeNull(); + }); + + it("notifies subscribers when isRunning toggles between calls", async () => { + const fake = createFakeStore(); + let resolveStart: ((value: DesktopDaemonStatus) => void) | undefined; + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: () => + new Promise((resolve) => { + resolveStart = resolve; + }), + }); + + const runningSnapshots: boolean[] = []; + service.subscribe(() => { + runningSnapshots.push(service.isRunning()); + }); + + const startPromise = service.start(); + expect(service.isRunning()).toBe(true); + expect(runningSnapshots).toEqual([true]); + + resolveStart?.(makeStatus()); + await startPromise; + + expect(service.isRunning()).toBe(false); + expect(runningSnapshots).toEqual([true, false]); + }); + + it("clears the error and notifies subscribers when retry begins", async () => { + const fake = createFakeStore(); + const startMock = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("ipc broke")) + .mockResolvedValueOnce(makeStatus()); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: () => startMock(), + }); + + await service.start(); + expect(service.getLastError()).toBe("ipc broke"); + + const errorSnapshots: Array = []; + service.subscribe(() => { + errorSnapshots.push(service.getLastError()); + }); + + await service.start(); + expect(errorSnapshots[0]).toBeNull(); + expect(service.getLastError()).toBeNull(); + }); + + it("recordError surfaces an external error and notifies subscribers", () => { + const fake = createFakeStore(); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => makeStatus(), + }); + const notifications = vi.fn(); + service.subscribe(notifications); + + service.recordError("settings file unreadable"); + + expect(service.getLastError()).toBe("settings file unreadable"); + expect(notifications).toHaveBeenCalledTimes(1); + }); + + it("stops notifying after a subscriber unsubscribes", async () => { + const fake = createFakeStore(); + let notifications = 0; + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => makeStatus({ listen: null }), + }); + const unsubscribe = service.subscribe(() => { + notifications += 1; + }); + + await service.start(); + const countAfterFirst = notifications; + expect(countAfterFirst).toBeGreaterThan(0); + + unsubscribe(); + await service.start(); + expect(notifications).toBe(countAfterFirst); + }); +}); + +describe("upsertDesktopDaemonConnection", () => { + it("upserts a valid desktop daemon status", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus()); + + expect(result).toEqual({ ok: true }); + expect(fake.upserts).toEqual([ + { listenAddress: "127.0.0.1:6868", serverId: "srv_desktop", hostname: "desktop" }, + ]); + }); + + it("rejects a missing listen address without upserting", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null })); + + expect(result).toEqual({ + ok: false, + error: "Desktop daemon did not return a listen address.", + }); + expect(fake.upserts).toEqual([]); + }); + + it("rejects a missing server id without upserting", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ serverId: "" })); + + expect(result).toEqual({ + ok: false, + error: "Desktop daemon did not return a server id.", + }); + expect(fake.upserts).toEqual([]); + }); + + it("rejects an unsupported listen address without upserting", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: "???" })); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.error).toContain("unsupported listen address"); + expect(fake.upserts).toEqual([]); + }); +}); diff --git a/packages/app/src/runtime/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts new file mode 100644 index 000000000..7ec466442 --- /dev/null +++ b/packages/app/src/runtime/daemon-start-service.ts @@ -0,0 +1,144 @@ +import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; +import { connectionFromListen } from "@/types/host-connection"; +import type { HostRuntimeStore } from "@/runtime/host-runtime"; + +export type DaemonStartResult = { ok: true } | { ok: false; error: string }; + +type DaemonConnectionStore = Pick; + +export interface DaemonStartServiceDeps { + store: DaemonConnectionStore; + startDesktopDaemon?: () => Promise; +} + +export async function upsertDesktopDaemonConnection( + store: DaemonConnectionStore, + daemon: DesktopDaemonStatus, +): Promise { + const listenAddress = daemon.listen?.trim() ?? ""; + const serverId = daemon.serverId.trim(); + if (!listenAddress) { + return { ok: false, error: "Desktop daemon did not return a listen address." }; + } + if (!serverId) { + return { ok: false, error: "Desktop daemon did not return a server id." }; + } + if (!connectionFromListen(listenAddress)) { + return { + ok: false, + error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`, + }; + } + await store.upsertConnectionFromListen({ + listenAddress, + serverId, + hostname: daemon.hostname, + }); + return { ok: true }; +} + +export class DaemonStartService { + private readonly store: DaemonConnectionStore; + private readonly invokeStartDesktopDaemon: () => Promise; + private readonly listeners = new Set<() => void>(); + private lastError: string | null = null; + private inFlightCount = 0; + + constructor(deps: DaemonStartServiceDeps) { + this.store = deps.store; + this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon; + } + + async start(): Promise { + this.beginRequest(); + try { + const daemon = await this.invokeStartDesktopDaemon(); + const result = await upsertDesktopDaemonConnection(this.store, daemon); + return result.ok ? result : this.fail(result.error); + } catch (error) { + return this.fail(error instanceof Error ? error.message : String(error)); + } finally { + this.endRequest(); + } + } + + getLastError(): string | null { + return this.lastError; + } + + recordError(message: string): void { + this.setLastError(message); + } + + isRunning(): boolean { + return this.inFlightCount > 0; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private fail(message: string): DaemonStartResult { + this.setLastError(message); + return { ok: false, error: message }; + } + + private setLastError(value: string | null): void { + if (this.lastError === value) { + return; + } + this.lastError = value; + this.notify(); + } + + private beginRequest(): void { + const becameRunning = this.inFlightCount === 0; + this.inFlightCount += 1; + const errorChanged = this.lastError !== null; + this.lastError = null; + if (becameRunning || errorChanged) { + this.notify(); + } + } + + private endRequest(): void { + this.inFlightCount = Math.max(0, this.inFlightCount - 1); + if (this.inFlightCount === 0) { + this.notify(); + } + } + + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } +} + +let singletonDaemonStartService: DaemonStartService | null = null; +const DAEMON_START_SERVICE_GLOBAL_KEY = "__ottoDaemonStartService"; + +type DaemonStartServiceGlobal = typeof globalThis & { + [DAEMON_START_SERVICE_GLOBAL_KEY]?: DaemonStartService; +}; + +export function getDaemonStartService(deps: DaemonStartServiceDeps): DaemonStartService { + if (singletonDaemonStartService) { + return singletonDaemonStartService; + } + + const runtimeGlobal = globalThis as DaemonStartServiceGlobal; + if (runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY]) { + singletonDaemonStartService = runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] ?? null; + if (singletonDaemonStartService) { + return singletonDaemonStartService; + } + } + + singletonDaemonStartService = new DaemonStartService(deps); + runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] = singletonDaemonStartService; + return singletonDaemonStartService; +} diff --git a/packages/app/src/runtime/host-features.ts b/packages/app/src/runtime/host-features.ts new file mode 100644 index 000000000..e513c3364 --- /dev/null +++ b/packages/app/src/runtime/host-features.ts @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import { useShallow } from "zustand/shallow"; +import type { DaemonServerInfo } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; + +export type HostFeatureName = keyof NonNullable; + +export interface HostFeatureSessionState { + sessions: Record< + string, + | { + serverInfo: DaemonServerInfo | null; + } + | undefined + >; +} + +export function hostSupportsFeature( + serverInfo: DaemonServerInfo | null | undefined, + feature: HostFeatureName, +): boolean { + return serverInfo?.features?.[feature] === true; +} + +export function selectHostFeature( + state: HostFeatureSessionState, + serverId: string, + feature: HostFeatureName, +): boolean { + return hostSupportsFeature(state.sessions[serverId]?.serverInfo, feature); +} + +export function useHostFeature( + serverId: string | null | undefined, + feature: HostFeatureName, +): boolean { + const normalizedServerId = serverId?.trim() ?? ""; + return useSessionStore((state) => selectHostFeature(state, normalizedServerId, feature)); +} + +export function useHostFeatureMap( + serverIds: readonly string[], + feature: HostFeatureName, +): ReadonlyMap { + const flags = useSessionStore( + useShallow((state) => serverIds.map((serverId) => selectHostFeature(state, serverId, feature))), + ); + + return useMemo( + () => new Map(serverIds.map((serverId, index) => [serverId, flags[index] === true] as const)), + [flags, serverIds], + ); +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts new file mode 100644 index 000000000..b7d850bff --- /dev/null +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -0,0 +1,2217 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.hoisted(() => { + Object.defineProperty(globalThis, "__DEV__", { value: false, configurable: true }); +}); +import type { + DaemonClient, + ConnectionState, + FetchAgentsEntry, + FetchAgentsOptions, +} from "@otto-code/client/internal/daemon-client"; +import type { ConnectionOffer } from "@otto-code/protocol/connection-offer"; +import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { queryClient } from "@/data/query-client"; +import { + HostRuntimeController, + HostRuntimeStore, + readInitialDaemonConnectionHint, + type HostRuntimeControllerDeps, + type HostRuntimeStorage, +} from "./host-runtime"; + +vi.mock("@/browser-automation/handler", () => ({ + mountBrowserAutomationDaemonClientHandler: vi.fn(() => () => undefined), +})); + +class FakeDaemonClient { + private state: ConnectionState = { status: "idle" }; + private listeners = new Set<(status: ConnectionState) => void>(); + private error: string | null = null; + private heartbeatRttMs: number | null = null; + private latencyMeasurementFailure: Error | null = null; + private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; + public connectCalls = 0; + public fetchAgentsCalls: FetchAgentsOptions[] = []; + public fetchAgentsResponses: Awaited>[] = []; + + async connect(): Promise { + this.connectCalls += 1; + this.setConnectionState({ status: "connected" }); + } + + async close(): Promise { + this.setConnectionState({ status: "disconnected", reason: "client_closed" }); + } + + ensureConnected(): void { + if (this.state.status !== "connected") { + this.setConnectionState({ status: "connected" }); + } + } + + getConnectionState(): ConnectionState { + return this.state; + } + + subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { + this.listeners.add(listener); + listener(this.state); + return () => { + this.listeners.delete(listener); + }; + } + + get lastError(): string | null { + return this.error; + } + + async fetchAgents( + options?: FetchAgentsOptions, + ): Promise>> { + this.fetchAgentsCalls.push(options ?? {}); + const queued = this.fetchAgentsResponses.shift(); + if (queued) { + return queued; + } + return makeFetchAgentsPayload({ + entries: [], + subscriptionId: options?.subscribe?.subscriptionId ?? undefined, + }); + } + + async ping(): Promise<{ rttMs: number }> { + return { rttMs: 0 }; + } + + async measureLatency(params?: { timeoutMs?: number }): Promise { + this.latencyMeasurementsRequested.push(params ?? {}); + if (this.latencyMeasurementFailure) { + throw this.latencyMeasurementFailure; + } + const result = await this.ping(); + return result.rttMs; + } + + setReconnectEnabled(_enabled: boolean): void {} + + getLastLivenessRttMs(): number | null { + return this.heartbeatRttMs; + } + + heartbeatReportsRtt(rttMs: number | null): void { + this.heartbeatRttMs = rttMs; + } + + latencyMeasurementsFailWith(message: string): void { + this.latencyMeasurementFailure = new Error(message); + } + + latencyMeasurements(): Array<{ timeoutMs?: number }> { + return this.latencyMeasurementsRequested; + } + + clearLatencyMeasurements(): void { + this.latencyMeasurementsRequested = []; + } + + isDisposed(): boolean { + return this.state.status === "disconnected" && this.state.reason === "client_closed"; + } + + setConnectionState(next: ConnectionState): void { + this.state = next; + if (next.status === "disconnected") { + this.error = next.reason ?? this.error; + } + for (const listener of this.listeners) { + listener(next); + } + } +} + +afterEach(() => { + vi.useRealTimers(); + delete (globalThis as Record).__OTTO_INITIAL_DAEMON_CONNECTION__; + delete (globalThis as { window?: unknown }).window; +}); + +function useHostRuntimeClock(): void { + vi.useFakeTimers({ + toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "performance"], + }); +} + +function makeFetchAgentsPayload(input: { + entries: FetchAgentsEntry[]; + hasMore?: boolean; + nextCursor?: string | null; + subscriptionId?: string; +}): Awaited> { + return { + entries: input.entries, + pageInfo: { + nextCursor: input.nextCursor ?? null, + prevCursor: null, + hasMore: input.hasMore ?? false, + } as Awaited>["pageInfo"], + ...(input.subscriptionId ? { subscriptionId: input.subscriptionId } : {}), + requestId: "req_test", + }; +} + +function makeFetchAgentsEntry(input: { + id: string; + cwd: string; + updatedAt: string; + title?: string | null; + requiresAttention?: boolean; + attentionReason?: "permission" | "error" | null; + archivedAt?: string | null; +}): FetchAgentsEntry { + return { + agent: { + id: input.id, + provider: "codex", + status: "idle", + createdAt: input.updatedAt, + updatedAt: input.updatedAt, + lastUserMessageAt: null, + lastError: undefined, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: input.title ?? null, + cwd: input.cwd, + model: null, + thinkingOptionId: null, + requiresAttention: input.requiresAttention ?? false, + attentionReason: input.attentionReason ?? null, + attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null, + archivedAt: input.archivedAt ?? null, + labels: {}, + }, + project: { + projectKey: input.cwd, + projectName: "workspace", + checkout: { + cwd: input.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isOttoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +function makeHost(input?: Partial): HostProfile { + const direct: HostConnection = { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }; + const relay: HostConnection = { + id: "relay:relay.otto-code.ai:443", + type: "relay", + relayEndpoint: "relay.otto-code.ai:443", + daemonPublicKeyB64: "pk_test", + }; + + return { + serverId: input?.serverId ?? "srv_test", + label: input?.label ?? "test host", + lifecycle: input?.lifecycle ?? {}, + connections: input?.connections ?? [direct, relay], + preferredConnectionId: input?.preferredConnectionId ?? direct.id, + createdAt: input?.createdAt ?? new Date(0).toISOString(), + updatedAt: input?.updatedAt ?? new Date(0).toISOString(), + }; +} + +function makeOffer(input?: Partial): ConnectionOffer { + return { + v: 2, + serverId: input?.serverId ?? "srv_offer", + daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer", + relay: { + endpoint: input?.relay?.endpoint ?? "relay.otto-code.ai:443", + useTls: input?.relay?.useTls ?? false, + }, + }; +} + +function encodeOfferUrl(payload: unknown): string { + const encoded = Buffer.from(JSON.stringify(payload), "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + return `https://app.otto-code.ai/#offer=${encoded}`; +} + +function makeDeps( + latencyByConnectionId: Record, + createdClients: FakeDaemonClient[], +): HostRuntimeControllerDeps { + return { + createClient: () => { + const client = new FakeDaemonClient(); + createdClients.push(client); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => { + const readLatency = (): number => { + const value = latencyByConnectionId[connection.id]; + if (value instanceof Error) { + throw value; + } + if (typeof value !== "number") { + throw new Error(`missing latency for ${connection.id}`); + } + return value; + }; + readLatency(); + const client = new FakeDaemonClient(); + client.connectCalls = 1; + client.setConnectionState({ status: "connected" }); + client.ping = async () => ({ rttMs: readLatency() }); + createdClients.push(client); + return { + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }; +} + +function createDeferred() { + let resolve: ((value: T | PromiseLike) => void) | null = null; + let reject: ((reason?: unknown) => void) | null = null; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { + promise, + resolve: (value: T | PromiseLike) => resolve?.(value), + reject: (reason?: unknown) => reject?.(reason), + }; +} + +function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient { + const client = new FakeDaemonClient(); + client.connectCalls = 1; + client.setConnectionState({ status: "connected" }); + client.ping = async () => ({ rttMs: latencyMs }); + return client; +} + +function createMemoryHostRuntimeStorage(entries: Record = {}): HostRuntimeStorage { + const values = new Map(Object.entries(entries)); + return { + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + }; +} + +function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean): Promise { + if (predicate()) { + return Promise.resolve(); + } + return new Promise((resolve) => { + let unsubscribe = (): void => {}; + unsubscribe = store.subscribeHostList(() => { + if (!predicate()) { + return; + } + unsubscribe(); + resolve(); + }); + }); +} + +class BrowserClientLifecycle { + public active: Array<{ serverId: string; connectionId: string }> = []; + + mount(input: { host: HostProfile; connection: HostConnection }): () => void { + const entry = { serverId: input.host.serverId, connectionId: input.connection.id }; + this.active.push(entry); + return () => { + this.active = this.active.filter((current) => current !== entry); + }; + } +} + +describe("HostRuntimeController", () => { + it("replaces the active relay client when re-pairing changes the daemon public key", async () => { + const oldRelay: HostConnection = { + id: "relay:wss:relay.otto-code.ai:443", + type: "relay", + relayEndpoint: "relay.otto-code.ai:443", + useTls: true, + daemonPublicKeyB64: "pk_old", + }; + const newRelay: HostConnection = { + ...oldRelay, + daemonPublicKeyB64: "pk_new", + }; + const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = []; + const controller = new HostRuntimeController({ + host: makeHost({ + connections: [oldRelay], + preferredConnectionId: oldRelay.id, + }), + deps: { + createClient: ({ connection }) => { + const client = new FakeDaemonClient(); + createdClients.push({ client, connection }); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: oldRelay.id }); + expect(controller.getSnapshot().client).toBe(createdClients[0]?.client); + + await controller.updateHost( + makeHost({ + connections: [newRelay], + preferredConnectionId: newRelay.id, + }), + ); + + expect(createdClients.map((entry) => entry.connection)).toEqual([oldRelay, newRelay]); + expect(createdClients[0]?.client.isDisposed()).toBe(true); + expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); + }); + + it("keeps known hosts in connecting when a created client reports idle during connect", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const idleClient = new FakeDaemonClient(); + const deps: HostRuntimeControllerDeps = { + createClient: () => idleClient as unknown as DaemonClient, + connectToDaemon: async () => { + throw new Error("probe unavailable"); + }, + getClientId: async () => "cid_test_runtime", + }; + const controller = new HostRuntimeController({ + host, + deps, + }); + + idleClient.connect = async () => { + idleClient.connectCalls += 1; + // Intentionally do not emit a connected state; stay in idle. + }; + + await controller.activateConnection({ connectionId: "direct:lan:6868" }); + + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + expect(controller.getSnapshot().connectionStatus).toBe("connecting"); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("initial_loading"); + }); + + it("passes resolved client id into created active clients", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const seenClientIds: string[] = []; + const fakeClient = new FakeDaemonClient(); + const controller = new HostRuntimeController({ + host, + deps: { + createClient: ({ clientId }) => { + seenClientIds.push(clientId); + return fakeClient as unknown as DaemonClient; + }, + connectToDaemon: async () => { + throw new Error("probe unavailable"); + }, + getClientId: async () => "cid_runtime_stable", + }, + }); + + await controller.activateConnection({ connectionId: "direct:lan:6868" }); + + expect(seenClientIds).toEqual(["cid_runtime_stable"]); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + }); + + it("keeps browser client lifecycle tied to the active host runtime client", async () => { + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const fakeClient = makeConnectedProbeClient(12); + const lifecycle = new BrowserClientLifecycle(); + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile, connection }) => ({ + client: makeConnectedProbeClient(10) as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_runtime_stable", + mountClientHandlers: (input) => lifecycle.mount(input), + }, + }); + + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: "direct:lan:6868", + existingClient: fakeClient as unknown as DaemonClient, + }, + }); + + expect(lifecycle.active).toEqual([{ serverId: "srv_test", connectionId: "direct:lan:6868" }]); + + await controller.stop(); + + expect(lifecycle.active).toEqual([]); + }); + + it("adopts the first successful probe on startup", async () => { + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 82, + "relay:relay.otto-code.ai:443": 18, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + + const snapshot = controller.getSnapshot(); + expect(snapshot.activeConnectionId).toBe("direct:lan:6868"); + expect(snapshot.connectionStatus).toBe("online"); + expect(clients).toHaveLength(2); + expect(snapshot.client).toBe(clients[0] as unknown as DaemonClient); + expect(clients[0]?.connectCalls).toBe(1); + expect(clients[1]?.isDisposed()).toBe(true); + }); + + it("activates the first successful probe without waiting for slower probes", async () => { + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const slowPing = createDeferred(); + const clients: FakeDaemonClient[] = []; + + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("should adopt the probe client"); + }, + connectToDaemon: async ({ host: hostProfile, connection }) => { + const client = makeConnectedProbeClient(connection.id === "direct:lan:6868" ? 12 : 30); + if (connection.id === "relay:relay.otto-code.ai:443") { + client.ping = async () => ({ rttMs: await slowPing.promise }); + } + clients.push(client); + return { + client: client as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + const probeCycle = controller.runProbeCycleNow(); + + const timeoutAt = Date.now() + 200; + while (Date.now() < timeoutAt) { + const snapshot = controller.getSnapshot(); + if ( + snapshot.activeConnectionId === "direct:lan:6868" && + snapshot.connectionStatus === "online" + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + + slowPing.resolve(30); + await probeCycle; + }); + + it("ranks the live connection by its heartbeat RTT without pinging it again", async () => { + useHostRuntimeClock(); + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const probeAttempts: string[] = []; + const latencies: Record = { + "direct:lan:6868": 12, + "relay:relay.otto-code.ai:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("should adopt probe clients"); + }, + connectToDaemon: async ({ host: hostProfile, connection }) => { + probeAttempts.push(connection.id); + const value = latencies[connection.id]; + if (value instanceof Error) { + throw value; + } + if (typeof value !== "number") { + throw new Error(`missing latency for ${connection.id}`); + } + return { + client: makeConnectedProbeClient(value) as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.start({ autoProbe: false }); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + + probeAttempts.length = 0; + const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient; + activeClient.heartbeatReportsRtt(42); + activeClient.clearLatencyMeasurements(); + activeClient.ping = async () => ({ rttMs: 9 }); + await vi.advanceTimersByTimeAsync(10_000); + await controller.runProbeCycleNow(); + + expect(probeAttempts).toEqual([]); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + expect(controller.getSnapshot().probeByConnectionId.get("direct:lan:6868")).toEqual({ + status: "available", + latencyMs: 42, + }); + expect(activeClient.latencyMeasurements()).toEqual([]); + }); + + it("rejects probes that resolve to a different server id", async () => { + const host = makeHost({ + serverId: "srv_old", + connections: [ + { + id: "direct:localhost:6868", + type: "directTcp", + endpoint: "localhost:6868", + }, + ], + }); + const mismatchedClient = makeConnectedProbeClient(8); + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("should not create active client"); + }, + connectToDaemon: async () => ({ + client: mismatchedClient as unknown as DaemonClient, + serverId: "srv_current", + hostname: "current host", + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.start({ autoProbe: false }); + + expect(controller.getSnapshot().connectionStatus).toBe("connecting"); + expect(controller.getSnapshot().activeConnectionId).toBeNull(); + expect(controller.getSnapshot().probeByConnectionId.get("direct:localhost:6868")).toEqual({ + status: "unavailable", + latencyMs: null, + }); + expect(mismatchedClient.isDisposed()).toBe(true); + }); + + it("keeps the live connection when one probe cycle looks slow", async () => { + useHostRuntimeClock(); + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 15, + "relay:relay.otto-code.ai:443": 55, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + const initialClient = controller.getSnapshot().client; + expect(initialClient).toBeTruthy(); + + const activeClient = initialClient as unknown as FakeDaemonClient; + activeClient.heartbeatReportsRtt(200); + activeClient.latencyMeasurementsFailWith("active measurement failed"); + latencies["relay:relay.otto-code.ai:443"] = 42; + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + + const snapshot = controller.getSnapshot(); + expect(snapshot.activeConnectionId).toBe("direct:lan:6868"); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.client).toBe(initialClient); + expect(activeClient.isDisposed()).toBe(false); + }); + + it("does not mark the live connection unavailable before its first heartbeat resolves", async () => { + useHostRuntimeClock(); + const direct: HostConnection = { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }; + const host = makeHost({ + connections: [direct], + preferredConnectionId: direct.id, + }); + const activeClient = new FakeDaemonClient(); + activeClient.setConnectionState({ status: "connected" }); + activeClient.latencyMeasurementsFailWith("heartbeat has not resolved"); + const controller = new HostRuntimeController({ + host, + deps: makeDeps({ [direct.id]: 12 }, []), + }); + + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: direct.id, + existingClient: activeClient as unknown as DaemonClient, + }, + }); + + const snapshot = controller.getSnapshot(); + expect(snapshot.activeConnectionId).toBe(direct.id); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.probeByConnectionId.get(direct.id)).toEqual({ + status: "pending", + latencyMs: null, + }); + }); + + it("backs off inactive connection probes while a host is online", async () => { + useHostRuntimeClock(); + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 10, + "relay:relay.otto-code.ai:443": 50, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient; + const initialClientCount = clients.length; + const initialRelayProbe = controller + .getSnapshot() + .probeByConnectionId.get("relay:relay.otto-code.ai:443"); + + latencies["direct:lan:6868"] = 12; + latencies["relay:relay.otto-code.ai:443"] = 25; + activeClient.heartbeatReportsRtt(12); + await vi.advanceTimersByTimeAsync(60_000); + + await controller.runProbeCycleNow(); + + const snapshot = controller.getSnapshot(); + expect(clients.length).toBe(initialClientCount); + expect(snapshot.probeByConnectionId.get("direct:lan:6868")).toEqual({ + status: "available", + latencyMs: 12, + }); + expect(snapshot.probeByConnectionId.get("relay:relay.otto-code.ai:443")).toEqual( + initialRelayProbe, + ); + }); + + it("switches only after the faster alternative wins consecutive probes", async () => { + useHostRuntimeClock(); + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 15, + "relay:relay.otto-code.ai:443": 60, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient; + + latencies["direct:lan:6868"] = 95; + latencies["relay:relay.otto-code.ai:443"] = 30; + activeClient.heartbeatReportsRtt(95); + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + + let switched = controller.getSnapshot().activeConnectionId === "relay:relay.otto-code.ai:443"; + for (let index = 0; index < 6 && !switched; index += 1) { + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + switched = controller.getSnapshot().activeConnectionId === "relay:relay.otto-code.ai:443"; + } + expect(switched).toBe(true); + expect(controller.getSnapshot().client).not.toBeNull(); + }); + + it("does not switch on a transient latency spike", async () => { + useHostRuntimeClock(); + const host = makeHost({ preferredConnectionId: "direct:lan:6868" }); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 15, + "relay:relay.otto-code.ai:443": 80, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient; + + latencies["direct:lan:6868"] = 100; + latencies["relay:relay.otto-code.ai:443"] = 20; + activeClient.heartbeatReportsRtt(100); + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + + latencies["direct:lan:6868"] = 20; + latencies["relay:relay.otto-code.ai:443"] = 90; + activeClient.heartbeatReportsRtt(20); + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + + latencies["direct:lan:6868"] = 100; + latencies["relay:relay.otto-code.ai:443"] = 20; + activeClient.heartbeatReportsRtt(100); + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6868"); + + let switched = controller.getSnapshot().activeConnectionId === "relay:relay.otto-code.ai:443"; + for (let index = 0; index < 6 && !switched; index += 1) { + await vi.advanceTimersByTimeAsync(120_000); + await controller.runProbeCycleNow(); + switched = controller.getSnapshot().activeConnectionId === "relay:relay.otto-code.ai:443"; + } + expect(switched).toBe(true); + }); + + it("exposes one snapshot with active connection and status from same source", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 12, + "relay:relay.otto-code.ai:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + const observed = new Array>(); + const unsubscribe = controller.subscribe(() => { + observed.push(controller.getSnapshot()); + }); + + await controller.start({ autoProbe: false }); + + clients[0]?.setConnectionState({ + status: "disconnected", + reason: "transport closed", + }); + + const latest = observed[observed.length - 1]; + expect(latest?.activeConnectionId).toBe("direct:lan:6868"); + expect(latest?.connectionStatus).toBe("error"); + expect(latest?.lastError).toBe("transport closed"); + unsubscribe(); + }); + + it("preserves transport disconnect reasons on the runtime snapshot", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const clients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host, + deps: makeDeps( + { + "direct:lan:6868": 12, + }, + clients, + ), + }); + + await controller.start({ autoProbe: false }); + clients[0]?.setConnectionState({ + status: "disconnected", + reason: "transport closed", + }); + + expect(controller.getSnapshot()).toMatchObject({ + connectionStatus: "error", + lastError: "transport closed", + }); + }); + + it("does not emit legacy typed reason-code transition logs", async () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined); + try { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const clients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host, + deps: makeDeps( + { + "direct:lan:6868": 12, + }, + clients, + ), + }); + + await controller.start({ autoProbe: false }); + clients[0]?.setConnectionState({ + status: "disconnected", + reason: "transport closed", + }); + + const transitionPayloads = infoSpy.mock.calls + .filter((call) => call[0] === "[HostRuntimeTransition]") + .map((call) => call[1] as { reasonCode?: string | null }); + const lastTransition = transitionPayloads[transitionPayloads.length - 1] ?? null; + + expect(lastTransition?.reasonCode).toBeUndefined(); + } finally { + infoSpy.mockRestore(); + } + }); + + it("marks directory loading on first connection before any directory sync succeeds", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 12, + "relay:relay.otto-code.ai:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + + const snapshot = controller.getSnapshot(); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.hasEverLoadedAgentDirectory).toBe(false); + expect(snapshot.agentDirectoryStatus).toBe("initial_loading"); + }); + + it("keeps directory ready through reconnects after the first successful directory load", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 12, + "relay:relay.otto-code.ai:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + controller.markAgentDirectorySyncReady(); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + expect(controller.getSnapshot().hasEverLoadedAgentDirectory).toBe(true); + + clients[0]?.setConnectionState({ + status: "disconnected", + reason: "client_closed", + }); + expect(controller.getSnapshot().connectionStatus).toBe("offline"); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + + clients[0]?.setConnectionState({ status: "connected" }); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + expect(controller.getSnapshot().agentDirectoryStatus).toBe("ready"); + }); + + it("stores directory sync errors as non-blocking after a successful directory load", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 12, + "relay:relay.otto-code.ai:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + controller.markAgentDirectorySyncReady(); + controller.markAgentDirectorySyncError("bootstrap failed"); + + const snapshot = controller.getSnapshot(); + expect(snapshot.agentDirectoryStatus).toBe("error_after_ready"); + expect(snapshot.agentDirectoryError).toBe("bootstrap failed"); + expect(snapshot.hasEverLoadedAgentDirectory).toBe(true); + }); + + it("keeps online snapshots coupled to a live client reference", async () => { + const host = makeHost(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6868": 12, + "relay:relay.otto-code.ai:443": 65, + }; + const controller = new HostRuntimeController({ + host, + deps: makeDeps(latencies, clients), + }); + + const observed = new Array>(); + const unsubscribe = controller.subscribe(() => { + observed.push(controller.getSnapshot()); + }); + + await controller.start({ autoProbe: false }); + + for (const snapshot of observed) { + if (snapshot.connectionStatus === "online") { + expect(snapshot.client).toBeTruthy(); + } + } + expect(controller.getSnapshot().connectionStatus).toBe("online"); + expect(controller.getSnapshot().client).toBeTruthy(); + unsubscribe(); + }); + + it("ignores stale switch failures after a newer connection is already online", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + { + id: "relay:relay.otto-code.ai:443", + type: "relay", + relayEndpoint: "relay.otto-code.ai:443", + daemonPublicKeyB64: "pk_test", + }, + ], + }); + const firstConnectGate = createDeferred(); + const createdClients: FakeDaemonClient[] = []; + const deps: HostRuntimeControllerDeps = { + createClient: ({ connection }) => { + const client = new FakeDaemonClient(); + if (connection.id === "direct:lan:6868") { + client.connect = async () => { + client.connectCalls += 1; + await firstConnectGate.promise; + throw new Error("stale direct connect failed"); + }; + } + createdClients.push(client); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: makeConnectedProbeClient(10) as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }; + const controller = new HostRuntimeController({ + host, + deps, + }); + + const waitUntil = async (predicate: () => boolean, timeoutMs = 200): Promise => { + const timeoutAt = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= timeoutAt) { + throw new Error("timed out waiting for predicate"); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }; + + const switchDirect = controller.activateConnection({ connectionId: "direct:lan:6868" }); + await waitUntil(() => { + const snapshot = controller.getSnapshot(); + return ( + createdClients.length === 1 && + snapshot.activeConnectionId === "direct:lan:6868" && + snapshot.connectionStatus === "connecting" + ); + }); + + const switchRelay = controller.activateConnection({ + connectionId: "relay:relay.otto-code.ai:443", + }); + await waitUntil(() => { + const snapshot = controller.getSnapshot(); + return ( + snapshot.activeConnectionId === "relay:relay.otto-code.ai:443" && + snapshot.connectionStatus === "online" + ); + }); + + firstConnectGate.resolve(); + await Promise.allSettled([switchDirect, switchRelay]); + + const snapshot = controller.getSnapshot(); + expect(snapshot.activeConnectionId).toBe("relay:relay.otto-code.ai:443"); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.lastError).toBeNull(); + expect(createdClients).toHaveLength(2); + expect(createdClients[0]?.isDisposed()).toBe(true); + }); + + it("coalesces overlapping probe cycles instead of invalidating the in-flight result", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const slowProbe = createDeferred(); + let probeCalls = 0; + + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => { + probeCalls += 1; + const client = new FakeDaemonClient(); + client.connectCalls = 1; + client.setConnectionState({ status: "connected" }); + client.ping = async () => { + if (probeCalls === 1) { + return { rttMs: await slowProbe.promise }; + } + throw new Error("unexpected probe call"); + }; + return { + client: client as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + const first = controller.runProbeCycleNow(); + const second = controller.runProbeCycleNow(); + expect(probeCalls).toBe(1); + + slowProbe.resolve(900); + await Promise.all([first, second]); + const probeAfterCycle = controller.getSnapshot().probeByConnectionId.get("direct:lan:6868"); + expect(probeAfterCycle).toEqual({ + status: "available", + latencyMs: 900, + }); + }); + + it("keeps active client generation stable during background probe cycles", async () => { + useHostRuntimeClock(); + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const createdClients: FakeDaemonClient[] = []; + + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + const client = new FakeDaemonClient(); + createdClients.push(client); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host: hostProfile }) => { + const client = makeConnectedProbeClient(10); + return { + client: client as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.start({ autoProbe: false }); + const activeClientBeforeProbes = controller.getSnapshot().client; + const generationBeforeProbes = controller.getSnapshot().clientGeneration; + + await vi.advanceTimersByTimeAsync(10_000); + await controller.runProbeCycleNow(); + expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes); + expect(controller.getSnapshot().clientGeneration).toBe(generationBeforeProbes); + expect(createdClients).toHaveLength(0); + }); +}); + +describe("HostRuntimeStore", () => { + it("marks the host registry loaded after boot reads storage", async () => { + const previousOverride = process.env.EXPO_PUBLIC_LOCAL_DAEMON; + process.env.EXPO_PUBLIC_LOCAL_DAEMON = "not-an-endpoint"; + const store = new HostRuntimeStore({ + deps: { + createClient: () => { + throw new Error("createClient should not be called"); + }, + connectToDaemon: async () => { + throw new Error("connectToDaemon should not be called"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + try { + let hostListNotifications = 0; + let unsubscribeHostList = () => {}; + const registryLoaded = new Promise((resolve) => { + unsubscribeHostList = store.subscribeHostList(() => { + hostListNotifications += 1; + if (store.isHostRegistryLoaded()) { + unsubscribeHostList(); + resolve(); + } + }); + }); + + store.boot(); + await registryLoaded; + + expect(store.isHostRegistryLoaded()).toBe(true); + expect(hostListNotifications).toBe(2); + } finally { + if (previousOverride === undefined) { + delete process.env.EXPO_PUBLIC_LOCAL_DAEMON; + } else { + process.env.EXPO_PUBLIC_LOCAL_DAEMON = previousOverride; + } + } + }); + + it("bootstraps agent directory subscription when host transitions online", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + store.syncHosts([host]); + + const timeoutAt = Date.now() + 200; + while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toHaveLength(1); + expect(fakeClient.fetchAgentsCalls[0]).toEqual({ + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_test" }, + page: { limit: 200 }, + }); + + const snapshot = store.getSnapshot(host.serverId); + expect(snapshot?.agentDirectoryStatus).toBe("ready"); + expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("bootstraps agent directory immediately when connection goes online (no session required)", async () => { + const host = makeHost({ + serverId: "srv_no_session", + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + store.syncHosts([host]); + + const timeoutAt = Date.now() + 200; + while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toHaveLength(1); + expect(fakeClient.fetchAgentsCalls[0]).toEqual({ + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_no_session" }, + page: { limit: 200 }, + }); + + store.syncHosts([]); + }); + + it("invalidates the host-aggregated queries when a host comes online", async () => { + const host = makeHost({ + serverId: "srv_aggregate_invalidation", + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + // Simulate the cold-start race the invalidation exists for: the aggregated + // queries cached an empty result while the host was still connecting. + const seededKeys = [ + ["projects", host.serverId], + ["schedules", host.serverId], + ["artifacts", host.serverId, null], + ] as const; + for (const key of seededKeys) { + queryClient.setQueryData(key, { seededBeforeOnline: true }); + queryClient.getQueryCache().find({ queryKey: key })?.setState({ isInvalidated: false }); + } + + try { + store.syncHosts([host]); + + const timeoutAt = Date.now() + 200; + const allInvalidated = () => + seededKeys.every( + (key) => queryClient.getQueryCache().find({ queryKey: key })?.state.isInvalidated, + ); + while (!allInvalidated() && Date.now() < timeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(allInvalidated()).toBe(true); + } finally { + store.syncHosts([]); + for (const key of seededKeys) { + queryClient.removeQueries({ queryKey: key }); + } + } + }); + + it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { + const host = makeHost({ + serverId: "srv_legacy_workspace_daemon", + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "agent-legacy", + cwd: "/repo/legacy-app", + updatedAt: "2026-06-18T12:00:00.000Z", + title: "Legacy daemon agent", + }), + ], + subscriptionId: "app:srv_legacy_workspace_daemon", + }), + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "0.1.96", + }); + store.syncHosts([host]); + + const timeoutAt = Date.now() + 300; + while ( + (fakeClient.fetchAgentsCalls.length === 0 || + !useSessionStore.getState().sessions[host.serverId]?.workspaces.has("/repo/legacy-app")) && + Date.now() < timeoutAt + ) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toEqual([ + { + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, + page: { limit: 200 }, + }, + ]); + const session = useSessionStore.getState().sessions[host.serverId]; + expect(session?.agents.get("agent-legacy")?.workspaceId).toBe("/repo/legacy-app"); + expect(Array.from(session?.workspaces.values() ?? [])).toEqual([ + expect.objectContaining({ + id: "/repo/legacy-app", + workspaceDirectory: "/repo/legacy-app", + name: "legacy-app", + }), + ]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("fetches all pages during bootstrap within the active agent scope", async () => { + const host = makeHost({ + serverId: "srv_paged", + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "agent-recent", + cwd: "/workspaces/otto", + updatedAt: "2026-03-04T12:00:00.000Z", + title: "Recent agent", + }), + ], + hasMore: true, + nextCursor: "cursor-page-2", + subscriptionId: "app:srv_paged", + }), + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "agent-stale-attention", + cwd: "/workspaces/otto-pr67-review", + updatedAt: "2026-02-20T08:00:00.000Z", + title: "Needs triage", + requiresAttention: true, + attentionReason: "error", + }), + ], + hasMore: false, + }), + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + store.syncHosts([host]); + + const timeoutAt = Date.now() + 300; + while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < timeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toHaveLength(2); + expect(fakeClient.fetchAgentsCalls[0]).toEqual({ + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_paged" }, + page: { limit: 200 }, + }); + expect(fakeClient.fetchAgentsCalls[1]).toEqual({ + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + page: { limit: 200, cursor: "cursor-page-2" }, + }); + + let staleAgent = + useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? + null; + const staleTimeoutAt = Date.now() + 300; + while (!staleAgent && Date.now() < staleTimeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + staleAgent = + useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? + null; + } + expect(staleAgent?.requiresAttention).toBe(true); + expect(staleAgent?.attentionReason).toBe("error"); + + const snapshot = store.getSnapshot(host.serverId); + expect(snapshot?.agentDirectoryStatus).toBe("ready"); + expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("re-subscribes agent directory updates after reconnect", async () => { + const host = makeHost({ + serverId: "srv_resubscribe", + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + store.syncHosts([host]); + + const initialTimeoutAt = Date.now() + 200; + while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + fakeClient.setConnectionState({ + status: "disconnected", + reason: "client_closed", + }); + fakeClient.setConnectionState({ status: "connected" }); + + const reconnectTimeoutAt = Date.now() + 200; + while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toEqual([ + { + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_resubscribe" }, + page: { limit: 200 }, + }, + { + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_resubscribe" }, + page: { limit: 200 }, + }, + ]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("replaces stale active session state when active bootstrap omits an agent", async () => { + const host = makeHost({ + serverId: "srv_archived_rehydrate", + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [], + subscriptionId: "app:srv_archived_rehydrate", + }), + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + useSessionStore.getState().setAgents(host.serverId, () => { + const stale = makeFetchAgentsEntry({ + id: "agent-archived", + cwd: "/workspaces/otto", + updatedAt: "2026-03-30T15:29:00.000Z", + archivedAt: null, + title: "Stale active copy", + }).agent; + const staleAgent: Agent = { + ...stale, + serverId: host.serverId, + createdAt: new Date(stale.createdAt), + updatedAt: new Date(stale.updatedAt), + lastUserMessageAt: null, + lastActivityAt: new Date(stale.updatedAt), + archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null, + attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null, + parentAgentId: null, + }; + return new Map([[stale.id, staleAgent]]); + }); + + store.syncHosts([host]); + + const timeoutAt = Date.now() + 300; + while ( + useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived") && + Date.now() < timeoutAt + ) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe( + false, + ); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + + it("records unavailable startup probes when no connection can be established", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }, + ], + }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => { + throw new Error("create client failed"); + }, + connectToDaemon: async () => { + throw new Error("probe unavailable"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + store.syncHosts([host]); + let snapshot = store.getSnapshot(host.serverId); + const timeoutAt = Date.now() + 100; + while ( + snapshot?.probeByConnectionId.get("direct:lan:6868")?.status !== "unavailable" && + Date.now() < timeoutAt + ) { + await new Promise((resolve) => setTimeout(resolve, 0)); + snapshot = store.getSnapshot(host.serverId); + } + + expect(snapshot?.connectionStatus).toBe("connecting"); + expect(snapshot?.lastError).toBeNull(); + expect(snapshot?.probeByConnectionId.get("direct:lan:6868")).toEqual({ + status: "unavailable", + latencyMs: null, + }); + }); + + it("renameHost updates label in memory", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + // upsertDirectConnection goes through setHostsAndSync, which both sets + // this.hosts and syncs controllers — matching the real init path. + await store.upsertDirectConnection({ + serverId: "srv_rename", + endpoint: "lan:6868", + label: "old name", + }); + expect(store.getHosts().find((h) => h.serverId === "srv_rename")?.label).toBe("old name"); + + // persistHosts may throw in test env (no AsyncStorage/window), but the + // in-memory state should still be updated by setHostsAndSync. + await store.renameHost("srv_rename", "new name").catch(() => undefined); + + const renamed = store.getHosts().find((h) => h.serverId === "srv_rename"); + expect(renamed?.label).toBe("new name"); + + store.syncHosts([]); + }); + + it("preserves a manual host rename when desktop status re-advertises the daemon hostname", async () => { + const advertisedHostname = "macbook-pro.local"; + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: advertisedHostname, + }), + getClientId: async () => "cid_test_runtime", + }, + storage: createMemoryHostRuntimeStorage(), + }); + + try { + await store.upsertConnectionFromListen({ + listenAddress: "127.0.0.1:6868", + serverId: "srv_desktop", + hostname: advertisedHostname, + }); + await store.renameHost("srv_desktop", "mac-dev"); + + await store.upsertConnectionFromListen({ + listenAddress: "127.0.0.1:6868", + serverId: "srv_desktop", + hostname: advertisedHostname, + }); + + expect(store.getHosts().find((h) => h.serverId === "srv_desktop")?.label).toBe("mac-dev"); + } finally { + store.syncHosts([]); + } + }); + + it("upsertDirectConnection stores SSL and password settings", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertDirectConnection({ + serverId: "srv_tls_password", + endpoint: "example.otto.test:7443", + useTls: true, + password: "shared-secret", + label: "tls host", + }); + + const host = store.getHosts().find((entry) => entry.serverId === "srv_tls_password"); + expect(host?.connections).toEqual([ + { + id: "direct:example.otto.test:7443", + type: "directTcp", + endpoint: "example.otto.test:7443", + useTls: true, + password: "shared-secret", + }, + ]); + + store.syncHosts([]); + }); + + it("probeAndUpsertConnection learns the real server id before storing a direct host", async () => { + const connection: HostConnection = { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }; + const probeClient = makeConnectedProbeClient(5); + const seenProbeHosts: string[] = []; + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host, connection: probedConnection }) => { + seenProbeHosts.push(host.serverId); + expect(probedConnection).toEqual(connection); + return { + client: probeClient as unknown as DaemonClient, + serverId: "srv_real_direct", + hostname: "mbp", + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + const result = await store.probeAndUpsertConnection({ connection }); + + expect(result.serverId).toBe("srv_real_direct"); + expect(result.hostname).toBe("mbp"); + expect(seenProbeHosts).toEqual([""]); + expect(probeClient.isDisposed()).toBe(false); + expect(store.getHosts()).toMatchObject([ + { + serverId: "srv_real_direct", + label: "mbp", + connections: [connection], + }, + ]); + + store.syncHosts([]); + }); + + it("probeAndUpsertConnection replaces a matching placeholder host with the real server id", async () => { + const connection: HostConnection = { + id: "direct:lan:6868", + type: "directTcp", + endpoint: "lan:6868", + }; + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: "srv_real_direct", + hostname: "mbp", + }), + getClientId: async () => "cid_test_runtime", + }, + }); + ( + store as unknown as { + hosts: HostProfile[]; + } + ).hosts = [ + makeHost({ + serverId: "local:lan:6868", + label: "local:lan:6868", + connections: [connection], + preferredConnectionId: connection.id, + }), + ]; + + await store.probeAndUpsertConnection({ connection }); + + expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_real_direct"]); + expect(store.getHosts()[0]?.label).toBe("mbp"); + + store.syncHosts([]); + }); + + it("uses the advertised hostname when adding a relay host from a pairing offer", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.label).toBe("mbp"); + + store.syncHosts([]); + }); + + it("stores relay TLS from a pairing offer", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertConnectionFromOffer( + makeOffer({ + relay: { + endpoint: "relay.example.com:443", + useTls: true, + }, + }), + "tls relay", + ); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.connections).toEqual([ + { + id: "relay:wss:relay.example.com:443", + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_test_offer", + }, + ]); + + store.syncHosts([]); + }); + + it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + const oldPairingUrl = encodeOfferUrl({ + v: 2, + serverId: "srv_offer", + daemonPublicKeyB64: "pk_test_offer", + relay: { endpoint: "relay.otto-code.ai:443" }, + }); + + await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay"); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.connections).toEqual([ + { + id: "relay:wss:relay.otto-code.ai:443", + type: "relay", + relayEndpoint: "relay.otto-code.ai:443", + useTls: true, + daemonPublicKeyB64: "pk_test_offer", + }, + ]); + + store.syncHosts([]); + }); + + it("preserves the existing host label when re-pairing an existing relay host", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + storage: createMemoryHostRuntimeStorage(), + }); + + await store.upsertRelayConnection({ + serverId: "srv_offer", + relayEndpoint: "relay.otto-code.ai:443", + daemonPublicKeyB64: "pk_test_offer", + label: "Custom name", + }); + + await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + + const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); + expect(pairedHost?.label).toBe("Custom name"); + + store.syncHosts([]); + }); +}); + +describe("readInitialDaemonConnectionHint", () => { + it("returns null when no hint is present", () => { + expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull(); + }); + + it("parses a valid listen-only hint", () => { + (globalThis as Record).__OTTO_INITIAL_DAEMON_CONNECTION__ = { + listen: "localhost:6868", + }; + expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({ + listen: "localhost:6868", + useTls: false, + }); + }); + + it("preserves useTls when explicitly true", () => { + (globalThis as Record).__OTTO_INITIAL_DAEMON_CONNECTION__ = { + listen: "otto.example.com:443", + useTls: true, + }; + expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toEqual({ + listen: "otto.example.com:443", + useTls: true, + }); + }); + + it("ignores invalid shapes", () => { + (globalThis as Record).__OTTO_INITIAL_DAEMON_CONNECTION__ = "localhost:6868"; + expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull(); + + (globalThis as Record).__OTTO_INITIAL_DAEMON_CONNECTION__ = { + useTls: true, + }; + expect(readInitialDaemonConnectionHint({ isWebRuntime: true })).toBeNull(); + }); +}); + +describe("HostRuntimeStore initial connection hint bootstrap", () => { + it("attempts the explicit initial connection hint before default localhost bootstrap", async () => { + const seenProbes: { endpoint: string; useTls?: boolean }[] = []; + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ connection }) => { + if (connection.type === "directTcp") { + seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls }); + } + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: "srv_hint", + hostname: "hint host", + }; + }, + getClientId: async () => "cid_test_runtime", + readInitialConnectionHint: () => ({ + listen: "daemon-origin:6868", + useTls: true, + }), + }, + storage: createMemoryHostRuntimeStorage(), + }); + + const hostAdded = onceHostListMatches(store, () => store.getHosts().length > 0); + store.boot(); + await hostAdded; + + expect(seenProbes).toContainEqual({ endpoint: "daemon-origin:6868", useTls: true }); + const host = store.getHosts()[0]; + expect(host?.serverId).toBe("srv_hint"); + expect(host?.connections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ endpoint: "daemon-origin:6868", useTls: true }), + ]), + ); + + store.syncHosts([]); + }); + + it("does not infer window.location.host when no explicit hint is present", async () => { + const seenProbes: { endpoint: string; useTls?: boolean }[] = []; + const firstProbe = createDeferred(); + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ connection }) => { + if (connection.type === "directTcp") { + seenProbes.push({ endpoint: connection.endpoint, useTls: connection.useTls }); + } + firstProbe.resolve(); + throw new Error("probe unavailable"); + }, + getClientId: async () => "cid_test_runtime", + readInitialConnectionHint: () => null, + }, + storage: createMemoryHostRuntimeStorage(), + }); + + (globalThis as { window?: unknown }).window = { + location: { host: "metro-host:8081", protocol: "http:" }, + }; + store.boot(); + await firstProbe.promise; + + expect(seenProbes).not.toContainEqual(expect.objectContaining({ endpoint: "metro-host:8081" })); + expect(store.getHosts()).toHaveLength(0); + }); +}); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts new file mode 100644 index 000000000..1941e25ad --- /dev/null +++ b/packages/app/src/runtime/host-runtime.ts @@ -0,0 +1,2423 @@ +import { useSyncExternalStore, useMemo } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import equal from "fast-deep-equal/es6"; +import { + DaemonClient, + type ConnectionState, + type FetchAgentsEntry, + type FetchAgentsOptions, +} from "@otto-code/client/internal/daemon-client"; +import { + connectionFromListen, + normalizeStoredHostProfile, + upsertHostConnectionInProfiles, + registryHasConnection, + type HostConnection, + type HostProfile, +} from "@/types/host-connection"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl, + decodeOfferFragmentPayload, + normalizeHostPort, + shouldUseTlsForDefaultHostedRelay, +} from "@/utils/daemon-endpoints"; +import { resolveAppVersion } from "@/utils/app-version"; +import { ConnectionOfferSchema, type ConnectionOffer } from "@otto-code/protocol/connection-offer"; +import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { DESKTOP_DAEMON_SERVER_ID_QUERY_KEY } from "@/hooks/use-is-local-daemon"; +import { isWeb } from "@/constants/platform"; +import { connectToDaemon } from "@/utils/test-daemon-connection"; +import { getOrCreateClientId } from "@/utils/client-id"; +import { + selectBestConnection, + type ConnectionCandidate, + type ConnectionProbeState, +} from "@/utils/connection-selection"; +import { + buildLocalDaemonTransportUrl, + createDesktopLocalDaemonTransportFactory, +} from "@/desktop/daemon/desktop-daemon-transport"; +import { getDesktopHost } from "@/desktop/host"; +import { CLIENT_CAPS } from "@otto-code/protocol/client-capabilities"; +import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@otto-code/protocol/browser-automation/rpc-schemas"; +import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { + fetchLegacyDaemonWorkspaceDirectory, + shouldUseLegacyDaemonWorkspaceDirectory, +} from "@/workspace/legacy-daemon-workspaces"; +import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; +import { invalidateHostAggregateQueries } from "@/data/host-aggregate-query-keys"; +import { queryClient } from "@/data/query-client"; +import { + invalidateServerDataQueriesAfterReconnect, + mountServerDataPushRouter, +} from "@/data/push-router"; +import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; +import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; + +export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; +export type HostRegistryStatus = "loading" | "ready"; + +export type ActiveConnection = + | { type: "directTcp"; endpoint: string; display: string } + | { type: "directSocket"; endpoint: string; display: "socket" } + | { type: "directPipe"; endpoint: string; display: "pipe" } + | { type: "relay"; endpoint: string; display: "relay" }; + +export type HostRuntimeAgentDirectoryStatus = + | "idle" + | "initial_loading" + | "revalidating" + | "ready" + | "error_before_first_success" + | "error_after_ready"; + +export interface HostRuntimeSnapshot { + serverId: string; + activeConnectionId: string | null; + activeConnection: ActiveConnection | null; + connectionStatus: HostRuntimeConnectionStatus; + client: DaemonClient | null; + lastError: string | null; + lastOnlineAt: string | null; + agentDirectoryStatus: HostRuntimeAgentDirectoryStatus; + agentDirectoryError: string | null; + hasEverLoadedAgentDirectory: boolean; + probeByConnectionId: Map; + clientGeneration: number; +} + +type HostRuntimeSnapshotPatch = Partial>; + +function setSnapshotPatchField( + patch: HostRuntimeSnapshotPatch, + key: Key, + value: HostRuntimeSnapshot[Key], +): void { + patch[key] = value; +} + +export function isHostRuntimeConnected(snapshot: HostRuntimeSnapshot | null): boolean { + return snapshot?.connectionStatus === "online"; +} + +export function isHostRuntimeDirectoryLoading(snapshot: HostRuntimeSnapshot | null): boolean { + if (!snapshot) { + return true; + } + if ( + snapshot.agentDirectoryStatus === "initial_loading" || + snapshot.agentDirectoryStatus === "revalidating" + ) { + return true; + } + return ( + !snapshot.hasEverLoadedAgentDirectory && + (snapshot.connectionStatus === "connecting" || snapshot.connectionStatus === "online") + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function hashForLog(value: string): string { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) | 0; + } + return `h_${Math.abs(hash).toString(16)}`; +} + +export interface HostRuntimeControllerDeps { + createClient: (input: { + host: HostProfile; + connection: HostConnection; + clientId: string; + runtimeGeneration: number; + }) => DaemonClient; + connectToDaemon: (input: { + host: HostProfile; + connection: HostConnection; + timeoutMs?: number; + }) => Promise<{ + client: DaemonClient; + serverId: string; + hostname: string | null; + }>; + getClientId: () => Promise; + readInitialConnectionHint?: () => InitialDaemonConnectionHint | null; + mountClientHandlers?: (input: { + client: DaemonClient; + host: HostProfile; + connection: HostConnection; + }) => () => void; +} + +export interface HostRuntimeStorage { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; +} + +export interface HostRuntimeStartOptions { + autoProbe?: boolean; + initialConnection?: { + connectionId: string; + existingClient: DaemonClient; + }; +} + +const PROBE_TICK_MS = 2_000; +const PROBE_STEADY_MS = 10_000; +const PROBE_MAX_BACKOFF_MS = 30_000; +const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000; +const ADAPTIVE_SWITCH_THRESHOLD_MS = 40; +const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; +const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200; +const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; + +const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable = [ + { key: "updated_at", direction: "desc" }, +]; + +function readFetchAgentsHasMore( + pageInfo: Awaited>["pageInfo"], +): boolean { + const page = pageInfo as { + hasMore?: boolean; + hasMoreAfter?: boolean; + }; + if (typeof page.hasMore === "boolean") { + return page.hasMore; + } + if (typeof page.hasMoreAfter === "boolean") { + return page.hasMoreAfter; + } + return false; +} + +function readFetchAgentsNextCursor( + pageInfo: Awaited>["pageInfo"], +): string | null { + const page = pageInfo as { + nextCursor?: string | null; + afterCursor?: string | null; + }; + if (typeof page.nextCursor === "string" && page.nextCursor.length > 0) { + return page.nextCursor; + } + if (typeof page.afterCursor === "string" && page.afterCursor.length > 0) { + return page.afterCursor; + } + return null; +} + +interface AgentDirectoryFetchInput { + client: DaemonClient; + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +} + +interface AgentDirectoryFetchResult { + entries: FetchAgentsEntry[]; + subscriptionId: string | null; +} + +async function fetchCurrentAgentDirectory( + input: AgentDirectoryFetchInput, +): Promise { + const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT; + let cursor = input.page?.cursor ?? null; + let includeSubscribe = true; + let subscriptionId: string | null = null; + const entries: FetchAgentsEntry[] = []; + + while (true) { + const payload = await input.client.fetchAgents({ + scope: input.filter ? undefined : "active", + ...(input.filter ? { filter: input.filter } : {}), + sort: DEFAULT_AGENT_DIRECTORY_SORT, + ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), + page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, + }); + + entries.push(...payload.entries); + subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; + includeSubscribe = false; + + if (!readFetchAgentsHasMore(payload.pageInfo)) { + break; + } + + const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + return { entries, subscriptionId }; +} + +function toActiveConnection(connection: HostConnection): ActiveConnection { + if (connection.type === "directSocket") { + return { + type: "directSocket", + endpoint: connection.path, + display: "socket", + }; + } + if (connection.type === "directPipe") { + return { + type: "directPipe", + endpoint: connection.path, + display: "pipe", + }; + } + if (connection.type === "directTcp") { + return { + type: "directTcp", + endpoint: connection.endpoint, + display: connection.endpoint, + }; + } + return { + type: "relay", + endpoint: connection.relayEndpoint, + display: "relay", + }; +} + +type HostRuntimeConnectionMachineState = + | { tag: "booting" } + | { + tag: "connecting"; + activeConnectionId: string; + activeConnection: ActiveConnection; + } + | { + tag: "online"; + activeConnectionId: string; + activeConnection: ActiveConnection; + lastOnlineAt: string; + } + | { + tag: "offline"; + activeConnectionId: string | null; + activeConnection: ActiveConnection | null; + } + | { + tag: "error"; + activeConnectionId: string | null; + activeConnection: ActiveConnection | null; + message: string; + }; + +type HostRuntimeConnectionMachineEvent = + | { type: "select_connection"; connectionId: string; connection: ActiveConnection } + | { type: "client_state"; state: ConnectionState; lastError: string | null } + | { type: "connect_failed"; message: string } + | { type: "no_connections" } + | { type: "stopped" }; + +function extractPreviousConnectionRef(state: HostRuntimeConnectionMachineState): { + id: string | null; + connection: ActiveConnection | null; +} { + if ( + state.tag === "connecting" || + state.tag === "online" || + state.tag === "offline" || + state.tag === "error" + ) { + return { id: state.activeConnectionId, connection: state.activeConnection }; + } + return { id: null, connection: null }; +} + +function buildConnectionStateFromStatus( + previousActiveConnectionId: string, + previousActiveConnection: ActiveConnection, + event: Extract, +): HostRuntimeConnectionMachineState | null { + const status = event.state.status; + if (status === "connected") { + return { + tag: "online", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + lastOnlineAt: new Date().toISOString(), + }; + } + if (status === "connecting" || status === "idle") { + return { + tag: "connecting", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + if (status === "disposed") { + return { + tag: "offline", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + return null; +} + +function resolveConnectionStateResult( + previousActiveConnectionId: string, + previousActiveConnection: ActiveConnection, + event: Extract, +): HostRuntimeConnectionMachineState { + const statusResult = buildConnectionStateFromStatus( + previousActiveConnectionId, + previousActiveConnection, + event, + ); + if (statusResult) return statusResult; + + const disconnectedReason = + event.state.status === "disconnected" ? (event.state.reason ?? null) : null; + const reason = disconnectedReason ?? event.lastError ?? null; + if (!reason || reason === "client_closed") { + return { + tag: "offline", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + }; + } + return { + tag: "error", + activeConnectionId: previousActiveConnectionId, + activeConnection: previousActiveConnection, + message: reason, + }; +} + +function nextConnectionMachineState(input: { + state: HostRuntimeConnectionMachineState; + event: HostRuntimeConnectionMachineEvent; +}): HostRuntimeConnectionMachineState { + const { state, event } = input; + + if (event.type === "select_connection") { + return { + tag: "connecting", + activeConnectionId: event.connectionId, + activeConnection: event.connection, + }; + } + + if (event.type === "connect_failed") { + const failed = extractPreviousConnectionRef(state); + return { + tag: "error", + activeConnectionId: failed.id, + activeConnection: failed.connection, + message: event.message, + }; + } + + if (event.type === "no_connections" || event.type === "stopped") { + return { + tag: "offline", + activeConnectionId: null, + activeConnection: null, + }; + } + + const previous = extractPreviousConnectionRef(state); + if (!previous.id || !previous.connection) { + return state.tag === "booting" + ? state + : { + tag: "offline", + activeConnectionId: null, + activeConnection: null, + }; + } + + return resolveConnectionStateResult(previous.id, previous.connection, event); +} + +function toSnapshotConnectionPatch( + state: HostRuntimeConnectionMachineState, +): Pick< + HostRuntimeSnapshot, + "activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt" +> { + if (state.tag === "booting") { + return { + activeConnectionId: null, + activeConnection: null, + connectionStatus: "connecting", + lastError: null, + lastOnlineAt: null, + }; + } + if (state.tag === "connecting") { + return { + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, + connectionStatus: "connecting", + lastError: null, + lastOnlineAt: null, + }; + } + if (state.tag === "online") { + return { + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, + connectionStatus: "online", + lastError: null, + lastOnlineAt: state.lastOnlineAt, + }; + } + if (state.tag === "offline") { + return { + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, + connectionStatus: "offline", + lastError: null, + lastOnlineAt: null, + }; + } + return { + activeConnectionId: state.activeConnectionId, + activeConnection: state.activeConnection, + connectionStatus: "error", + lastError: state.message, + lastOnlineAt: null, + }; +} + +function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] { + return host.connections.map((connection) => ({ + connectionId: connection.id, + connection, + })); +} + +function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null { + if (!connectionId) { + return null; + } + return host.connections.find((connection) => connection.id === connectionId) ?? null; +} + +function probeIntervalForConnection( + firstSeenAt: number, + isActiveOnline: boolean, + hasActiveOnlineConnection: boolean, + now: number, +): number { + if (isActiveOnline) { + return PROBE_STEADY_MS; + } + if (hasActiveOnlineConnection) { + return PROBE_INACTIVE_WHILE_ONLINE_MS; + } + const age = now - firstSeenAt; + if (age < 10_000) return 2_000; + if (age < 30_000) return 5_000; + if (age < 60_000) return PROBE_STEADY_MS; + return PROBE_MAX_BACKOFF_MS; +} + +function createDefaultDeps(): HostRuntimeControllerDeps { + const browserHostAvailable = + typeof getDesktopHost()?.browser?.executeAutomationCommand === "function"; + const browserAutomationCapabilities = browserHostAvailable + ? { + [CLIENT_CAPS.browserHost]: { + supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES], + hostKind: "desktop app", + }, + } + : undefined; + + return { + createClient: ({ host, connection, clientId, runtimeGeneration }) => { + const localTransportFactory = createDesktopLocalDaemonTransportFactory(); + const base = { + suppressSendErrors: true, + clientId, + clientType: "mobile" as const, + appVersion: resolveAppVersion() ?? undefined, + runtimeGeneration, + ...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}), + }; + if (connection.type === "directSocket" || connection.type === "directPipe") { + return new DaemonClient({ + ...base, + ...(localTransportFactory ? { transportFactory: localTransportFactory } : {}), + url: buildLocalDaemonTransportUrl({ + transportType: connection.type === "directSocket" ? "socket" : "pipe", + transportPath: connection.path, + }), + }); + } + if (connection.type === "directTcp") { + return new DaemonClient({ + ...base, + url: buildDaemonWebSocketUrl(connection.endpoint, { + useTls: connection.useTls ?? false, + }), + ...(connection.password ? { password: connection.password } : {}), + }); + } + return new DaemonClient({ + ...base, + url: buildRelayWebSocketUrl({ + endpoint: connection.relayEndpoint, + useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), + serverId: host.serverId, + }), + e2ee: { + enabled: true, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + }, + }); + }, + connectToDaemon: ({ host, connection, timeoutMs }) => + connectToDaemon(connection, { + ...(host.serverId ? { serverId: host.serverId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(browserAutomationCapabilities ? { capabilities: browserAutomationCapabilities } : {}), + }), + getClientId: () => getOrCreateClientId(), + mountClientHandlers: ({ client, host }) => { + const unmountServerData = mountServerDataPushRouter({ + client, + queryClient, + serverId: host.serverId, + }); + if (!browserAutomationCapabilities) { + return unmountServerData; + } + const unmountBrowserAutomation = mountBrowserAutomationDaemonClientHandler(client, { + serverId: host.serverId, + }); + return () => { + unmountBrowserAutomation(); + unmountServerData(); + }; + }, + }; +} + +export class HostRuntimeController { + private host: HostProfile; + private deps: HostRuntimeControllerDeps; + private onReconcileServerId: ((oldId: string, newId: string) => void) | null; + private connectionMachineState: HostRuntimeConnectionMachineState; + private snapshot: HostRuntimeSnapshot; + private listeners = new Set<() => void>(); + private activeClient: DaemonClient | null = null; + private unsubscribeClientStatus: (() => void) | null = null; + private unsubscribeClientHandlers: (() => void) | null = null; + private probeIntervalHandle: ReturnType | null = null; + private started = false; + private connectionFirstSeenAt = new Map(); + private connectionLastProbedAt = new Map(); + private switchCandidateConnectionId: string | null = null; + private switchCandidateHitCount = 0; + private clientIdPromise: Promise | null = null; + private clientIdHash: string | null = null; + private switchRequestVersion = 0; + private probeRequestVersion = 0; + private probeCycleInFlight: Promise | null = null; + + constructor(input: { + host: HostProfile; + deps?: HostRuntimeControllerDeps; + onReconcileServerId?: (oldId: string, newId: string) => void; + }) { + this.host = input.host; + this.deps = input.deps ?? createDefaultDeps(); + this.onReconcileServerId = input.onReconcileServerId ?? null; + this.connectionMachineState = { + tag: "booting", + }; + this.snapshot = { + serverId: this.host.serverId, + ...toSnapshotConnectionPatch(this.connectionMachineState), + client: null, + agentDirectoryStatus: "idle", + agentDirectoryError: null, + hasEverLoadedAgentDirectory: false, + probeByConnectionId: new Map(), + clientGeneration: 0, + }; + } + + getSnapshot(): HostRuntimeSnapshot { + return this.snapshot; + } + + getClient(): DaemonClient | null { + return this.snapshot.client; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + async start(options?: HostRuntimeStartOptions): Promise { + if (this.started) { + return; + } + this.started = true; + this.trackConnectionFirstSeen(); + if (options?.initialConnection) { + await this.switchToConnection({ + connectionId: options.initialConnection.connectionId, + existingClient: options.initialConnection.existingClient, + }); + } + await this.runProbeCycleNow(); + if (options?.autoProbe !== false) { + this.probeIntervalHandle = setInterval(() => { + void this.runProbeCycleNow(); + }, PROBE_TICK_MS); + } + } + + async stop(): Promise { + this.switchRequestVersion += 1; + this.probeRequestVersion += 1; + this.started = false; + if (this.probeIntervalHandle) { + clearInterval(this.probeIntervalHandle); + this.probeIntervalHandle = null; + } + if (this.unsubscribeClientStatus) { + this.unsubscribeClientStatus(); + this.unsubscribeClientStatus = null; + } + if (this.unsubscribeClientHandlers) { + this.unsubscribeClientHandlers(); + this.unsubscribeClientHandlers = null; + } + if (this.activeClient) { + const prev = this.activeClient; + this.activeClient = null; + await prev.close().catch(() => undefined); + } + this.applyConnectionEvent({ type: "stopped" }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), + client: null, + }); + } + + async updateHost(host: HostProfile): Promise { + const activeConnectionId = this.snapshot.activeConnectionId; + const previousActiveConnection = findConnectionById(this.host, activeConnectionId); + this.host = host; + this.trackConnectionFirstSeen(); + const nextActiveConnection = findConnectionById(this.host, activeConnectionId); + if ( + activeConnectionId && + previousActiveConnection && + nextActiveConnection && + !equal(previousActiveConnection, nextActiveConnection) + ) { + this.connectionLastProbedAt.delete(activeConnectionId); + await this.switchToConnection({ connectionId: activeConnectionId }); + } + await this.runProbeCycleNow(); + } + + ensureConnected(): void { + this.activeClient?.ensureConnected(); + } + + markAgentDirectorySyncLoading(): void { + const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading"; + this.updateSnapshot({ + agentDirectoryStatus: status, + agentDirectoryError: null, + }); + } + + markAgentDirectorySyncReady(): void { + this.updateSnapshot({ + agentDirectoryStatus: "ready", + agentDirectoryError: null, + hasEverLoadedAgentDirectory: true, + }); + } + + markAgentDirectorySyncError(error: string): void { + const hasEverLoadedAgentDirectory = this.snapshot.hasEverLoadedAgentDirectory; + this.updateSnapshot({ + agentDirectoryStatus: hasEverLoadedAgentDirectory + ? "error_after_ready" + : "error_before_first_success", + agentDirectoryError: error, + hasEverLoadedAgentDirectory, + }); + } + + markAgentDirectorySyncIdle(): void { + this.updateSnapshot({ + agentDirectoryStatus: this.snapshot.hasEverLoadedAgentDirectory ? "ready" : "idle", + agentDirectoryError: null, + }); + } + + markStartupError(message: string): void { + this.applyConnectionEvent({ type: "connect_failed", message }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), + }); + } + + async activateConnection(input: { + connectionId: string; + existingClient?: DaemonClient; + }): Promise { + await this.switchToConnection(input); + } + + async runProbeCycleNow(): Promise { + if (this.probeCycleInFlight) { + return this.probeCycleInFlight; + } + + const cycle = this.runProbeCycle().finally(() => { + if (this.probeCycleInFlight === cycle) { + this.probeCycleInFlight = null; + } + }); + this.probeCycleInFlight = cycle; + return cycle; + } + + private async runProbeCycle(): Promise { + const requestVersion = ++this.probeRequestVersion; + if (this.host.connections.length === 0) { + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + this.applyConnectionEvent({ type: "no_connections" }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), + probeByConnectionId: new Map(), + }); + return; + } + + const now = performance.now(); + const isOnline = this.snapshot.connectionStatus === "online"; + const activeConnectionId = this.snapshot.activeConnectionId; + const hasActiveOnlineConnection = isOnline && activeConnectionId !== null; + + const connectionsToProbe = this.host.connections.filter((connection) => { + const lastProbed = this.connectionLastProbedAt.get(connection.id); + if (lastProbed == null) { + return true; + } + const firstSeen = this.connectionFirstSeenAt.get(connection.id) ?? now; + const isActiveOnline = isOnline && connection.id === activeConnectionId; + const interval = probeIntervalForConnection( + firstSeen, + isActiveOnline, + hasActiveOnlineConnection, + now, + ); + return now - lastProbed >= interval; + }); + + if (connectionsToProbe.length === 0) { + return; + } + + const probeByConnectionId = new Map(this.snapshot.probeByConnectionId); + for (const connection of connectionsToProbe) { + this.connectionLastProbedAt.set(connection.id, performance.now()); + const existingProbe = probeByConnectionId.get(connection.id); + const shouldPreserveActiveLatency = + isOnline && connection.id === activeConnectionId && existingProbe?.status === "available"; + if (!shouldPreserveActiveLatency) { + probeByConnectionId.set(connection.id, { + status: "pending", + latencyMs: null, + }); + } + } + this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); + + let remaining = connectionsToProbe.length; + let activationLock: Promise | null = null; + + const publishProbeState = (): void => { + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); + }; + + const maybeActivateFirstAvailable = async ( + connectionId: string, + client: DaemonClient, + ): Promise => { + while (!this.snapshot.activeConnectionId) { + if (!activationLock) { + activationLock = this.switchToConnection({ + connectionId, + expectedProbeVersion: requestVersion, + existingClient: client, + }).finally(() => { + activationLock = null; + }); + await activationLock; + return this.snapshot.activeConnectionId === connectionId; + } + await activationLock; + } + return false; + }; + + const finalizeProbeCycle = async (): Promise => { + if (remaining > 0 || !this.isCurrentProbeRequest(requestVersion)) { + return; + } + + const currentActiveConnectionId = this.snapshot.activeConnectionId; + const activeProbe = currentActiveConnectionId + ? probeByConnectionId.get(currentActiveConnectionId) + : null; + + if (!currentActiveConnectionId || !findConnectionById(this.host, currentActiveConnectionId)) { + const nextConnectionId = selectBestConnection({ + candidates: buildConnectionCandidates(this.host), + probeByConnectionId, + }); + if (nextConnectionId) { + await this.switchToConnection({ + connectionId: nextConnectionId, + expectedProbeVersion: requestVersion, + }); + } + return; + } + + if (activeProbe?.status === "unavailable") { + const nextConnectionId = selectBestConnection({ + candidates: buildConnectionCandidates(this.host), + probeByConnectionId, + }); + if (nextConnectionId && nextConnectionId !== currentActiveConnectionId) { + await this.switchToConnection({ + connectionId: nextConnectionId, + expectedProbeVersion: requestVersion, + }); + } + this.switchCandidateConnectionId = null; + this.switchCandidateHitCount = 0; + return; + } + + if (!activeProbe || activeProbe.status !== "available") { + return; + } + + const available = Array.from(probeByConnectionId.entries()) + .filter( + (entry): entry is [string, Extract] => + entry[1].status === "available", + ) + .map(([connectionId, probe]) => ({ + connectionId, + latencyMs: probe.latencyMs, + })) + .sort((left, right) => left.latencyMs - right.latencyMs); + + const fastest = available[0] ?? null; + if (!fastest || fastest.connectionId === currentActiveConnectionId) { + this.switchCandidateConnectionId = null; + this.switchCandidateHitCount = 0; + return; + } + + const improvement = activeProbe.latencyMs - fastest.latencyMs; + if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) { + this.switchCandidateConnectionId = null; + this.switchCandidateHitCount = 0; + return; + } + + if (this.switchCandidateConnectionId === fastest.connectionId) { + this.switchCandidateHitCount += 1; + } else { + this.switchCandidateConnectionId = fastest.connectionId; + this.switchCandidateHitCount = 1; + } + + if (this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES) { + this.switchCandidateConnectionId = null; + this.switchCandidateHitCount = 0; + await this.switchToConnection({ + connectionId: fastest.connectionId, + expectedProbeVersion: requestVersion, + }); + } + }; + + await new Promise((resolve) => { + const settleProbe = (): void => { + remaining -= 1; + void finalizeProbeCycle().finally(() => { + if (remaining === 0) { + resolve(); + } + }); + }; + + for (const connection of connectionsToProbe) { + void (async () => { + let connectedClient: DaemonClient | null = null; + let shouldCloseClient = false; + try { + const activeClient = + this.snapshot.connectionStatus === "online" && + this.snapshot.activeConnectionId === connection.id + ? this.snapshot.client + : null; + + if (activeClient) { + connectedClient = activeClient; + } else { + const { client, serverId } = await this.deps.connectToDaemon({ + host: this.host, + connection, + }); + if (serverId !== this.host.serverId) { + if (isPlaceholderServerId(this.host.serverId) && this.onReconcileServerId) { + this.onReconcileServerId(this.host.serverId, serverId); + } else { + await client.close().catch(() => undefined); + throw new Error( + `Connection resolved to ${serverId}, expected ${this.host.serverId}.`, + ); + } + } + connectedClient = client; + shouldCloseClient = true; + } + + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + + const activated = await maybeActivateFirstAvailable(connection.id, connectedClient); + shouldCloseClient = shouldCloseClient && !activated; + + if (activeClient) { + const rttMs = activeClient.getLastLivenessRttMs(); + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + if (rttMs !== null) { + probeByConnectionId.set(connection.id, { + status: "available", + latencyMs: rttMs, + }); + publishProbeState(); + } + return; + } + + const rttMs = await connectedClient.measureLatency({ timeoutMs: 5000 }); + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + + probeByConnectionId.set(connection.id, { + status: "available", + latencyMs: rttMs, + }); + publishProbeState(); + } catch { + if (this.isCurrentProbeRequest(requestVersion)) { + probeByConnectionId.set(connection.id, { + status: "unavailable", + latencyMs: null, + }); + publishProbeState(); + } + } finally { + if (connectedClient && shouldCloseClient) { + await connectedClient.close().catch(() => undefined); + } + settleProbe(); + } + })(); + } + }); + } + + private updateSnapshot(patch: HostRuntimeSnapshotPatch): void { + const preservedPatch: HostRuntimeSnapshotPatch = { ...patch }; + let hasChanged = this.host.serverId !== this.snapshot.serverId; + for (const key of Object.keys(patch) as (keyof HostRuntimeSnapshotPatch)[]) { + const incomingValue = patch[key]; + if (equal(this.snapshot[key], incomingValue)) { + setSnapshotPatchField(preservedPatch, key, this.snapshot[key]); + continue; + } + hasChanged = true; + } + if (!hasChanged) { + return; + } + this.snapshot = { + ...this.snapshot, + ...preservedPatch, + serverId: this.host.serverId, + }; + for (const listener of this.listeners) { + listener(); + } + } + + private applyConnectionEvent(event: HostRuntimeConnectionMachineEvent): void { + const previousState = this.connectionMachineState; + const nextState = nextConnectionMachineState({ + state: previousState, + event, + }); + this.connectionMachineState = nextState; + this.logConnectionTransition({ + from: previousState.tag, + to: nextState.tag, + event, + }); + } + + private logConnectionTransition(_input: { + from: HostRuntimeConnectionMachineState["tag"]; + to: HostRuntimeConnectionMachineState["tag"]; + event: HostRuntimeConnectionMachineEvent; + }): void { + // Intentionally empty - logging removed. + } + + private trackConnectionFirstSeen(): void { + const now = performance.now(); + const currentIds = new Set(this.host.connections.map((c) => c.id)); + for (const id of this.connectionFirstSeenAt.keys()) { + if (!currentIds.has(id)) { + this.connectionFirstSeenAt.delete(id); + this.connectionLastProbedAt.delete(id); + } + } + for (const connection of this.host.connections) { + if (!this.connectionFirstSeenAt.has(connection.id)) { + this.connectionFirstSeenAt.set(connection.id, now); + } + } + } + + private isCurrentSwitchRequest(version: number): boolean { + return version === this.switchRequestVersion; + } + + private isCurrentProbeRequest(version: number): boolean { + return version === this.probeRequestVersion; + } + + private canProceedForProbe(expectedProbeVersion: number | undefined): boolean { + if (expectedProbeVersion === undefined) { + return true; + } + return this.isCurrentProbeRequest(expectedProbeVersion); + } + + private async abortSwitchWithClient(client: DaemonClient | undefined): Promise { + if (client) { + await client.close().catch(() => undefined); + } + } + + private isSwitchStillValid(requestVersion: number, expectedProbeVersion?: number): boolean { + return ( + this.isCurrentSwitchRequest(requestVersion) && this.canProceedForProbe(expectedProbeVersion) + ); + } + + private async resolveClientIdForSwitch(args: { + existingClient: DaemonClient | undefined; + requestVersion: number; + }): Promise { + try { + return await this.resolveClientId(); + } catch (error) { + await this.abortSwitchWithClient(args.existingClient); + if (!this.isCurrentSwitchRequest(args.requestVersion)) { + return null; + } + const message = toErrorMessage(error); + this.applyConnectionEvent({ + type: "connect_failed", + message: `Failed to resolve client id: ${message}`, + }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), + }); + return null; + } + } + + private async disposePreviousActiveClient(): Promise { + if (this.unsubscribeClientStatus) { + this.unsubscribeClientStatus(); + this.unsubscribeClientStatus = null; + } + if (this.unsubscribeClientHandlers) { + this.unsubscribeClientHandlers(); + this.unsubscribeClientHandlers = null; + } + if (this.activeClient) { + const previousClient = this.activeClient; + this.activeClient = null; + await previousClient.close().catch(() => undefined); + } + } + + private buildAgentDirectoryStatusPatch(): Partial { + if (this.snapshot.hasEverLoadedAgentDirectory) return {}; + const tag = this.connectionMachineState.tag; + if (tag === "connecting" || tag === "online") { + return { agentDirectoryStatus: "initial_loading", agentDirectoryError: null }; + } + if (tag === "error") { + return { + agentDirectoryStatus: "error_before_first_success", + agentDirectoryError: this.connectionMachineState.message, + }; + } + return { agentDirectoryStatus: "idle", agentDirectoryError: null }; + } + + private async switchToConnection(input: { + connectionId: string; + expectedProbeVersion?: number; + existingClient?: DaemonClient; + }): Promise { + const { connectionId, expectedProbeVersion, existingClient } = input; + if (!this.canProceedForProbe(expectedProbeVersion)) { + await this.abortSwitchWithClient(existingClient); + return; + } + const connection = findConnectionById(this.host, connectionId); + if (!connection) { + await this.abortSwitchWithClient(existingClient); + return; + } + const requestVersion = ++this.switchRequestVersion; + + const clientId = await this.resolveClientIdForSwitch({ existingClient, requestVersion }); + if (clientId === null) return; + + if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { + await this.abortSwitchWithClient(existingClient); + return; + } + + await this.disposePreviousActiveClient(); + + if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { + await this.abortSwitchWithClient(existingClient); + return; + } + + const nextGeneration = this.snapshot.clientGeneration + 1; + if (existingClient) { + existingClient.setReconnectEnabled(true); + } + const client = + existingClient ?? + this.deps.createClient({ + host: this.host, + connection, + clientId, + runtimeGeneration: nextGeneration, + }); + + if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { + await client.close().catch(() => undefined); + return; + } + + this.activeClient = client; + this.unsubscribeClientHandlers = + this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; + this.applyConnectionEvent({ + type: "select_connection", + connectionId: connection.id, + connection: toActiveConnection(connection), + }); + this.snapshot = { + ...this.snapshot, + serverId: this.host.serverId, + ...toSnapshotConnectionPatch(this.connectionMachineState), + client, + clientGeneration: nextGeneration, + }; + for (const listener of this.listeners) { + listener(); + } + + this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { + return; + } + this.applyConnectionEvent({ + type: "client_state", + state, + lastError: client.lastError, + }); + const patch: HostRuntimeSnapshotPatch = { + ...toSnapshotConnectionPatch(this.connectionMachineState), + ...this.buildAgentDirectoryStatusPatch(), + }; + this.updateSnapshot(patch); + }); + + try { + if (!existingClient) { + await client.connect(); + } + } catch (error) { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { + return; + } + const message = toErrorMessage(error); + this.applyConnectionEvent({ + type: "connect_failed", + message, + }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState), + }); + } + } + + adoptReconciledServerId(newServerId: string): void { + this.host = { ...this.host, serverId: newServerId }; + this.snapshot = { ...this.snapshot, serverId: newServerId }; + for (const listener of this.listeners) { + listener(); + } + } + + private resolveClientId(): Promise { + if (!this.clientIdPromise) { + this.clientIdPromise = this.deps.getClientId().then((value) => { + this.clientIdHash = hashForLog(value); + return value; + }); + } + return this.clientIdPromise; + } +} + +const REGISTRY_STORAGE_KEY = "@otto:daemon-registry"; +const LOCALHOST_FALLBACK_ENDPOINT = "localhost:6868"; +const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; +const E2E_STORAGE_KEY = "@otto:e2e"; +const INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY = "__OTTO_INITIAL_DAEMON_CONNECTION__"; + +export interface InitialDaemonConnectionHint { + listen: string; + useTls?: boolean; +} + +function isInitialConnectionHintRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function readInitialDaemonConnectionHint(input?: { + isWebRuntime?: boolean; +}): InitialDaemonConnectionHint | null { + const isWebRuntime = input?.isWebRuntime ?? isWeb; + if (!isWebRuntime || typeof globalThis === "undefined") { + return null; + } + const value = (globalThis as Record)[INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY]; + if (!isInitialConnectionHintRecord(value)) { + return null; + } + const listen = typeof value.listen === "string" ? value.listen.trim() : ""; + if (!listen) { + return null; + } + return { + listen, + useTls: value.useTls === true, + }; +} + +function readConfiguredLocalDaemonOverride(): string | null { + const value = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim(); + return value && value.length > 0 ? value : null; +} + +export function hasConfiguredLocalDaemonOverride(): boolean { + return readConfiguredLocalDaemonOverride() !== null; +} + +function isPlaceholderServerId(serverId: string): boolean { + return serverId.startsWith("local:"); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function rekeyMap(map: Map, oldKey: string, newKey: string): void { + const value = map.get(oldKey); + if (value === undefined) { + return; + } + map.delete(oldKey); + map.set(newKey, value); +} + +export class HostRuntimeStore { + private controllers = new Map(); + private serverListeners = new Map void>>(); + private globalListeners = new Set<() => void>(); + private hostListListeners = new Set<() => void>(); + private version = 0; + private hostListVersion = 0; + private hostRegistryLoaded = false; + private hosts: HostProfile[] = []; + private hostRegistryStatus: HostRegistryStatus = "loading"; + private deps: HostRuntimeControllerDeps; + private lastConnectionStatusByServer = new Map(); + private agentDirectoryBootstrapInFlight = new Map>(); + private configuredOverrideBootstrapInFlight: Promise | null = null; + private bootStarted = false; + private storage: HostRuntimeStorage; + + constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage }) { + this.deps = input?.deps ?? createDefaultDeps(); + this.storage = input?.storage ?? AsyncStorage; + } + + // --- Host registry --- + + getHosts(): HostProfile[] { + return this.hosts; + } + + getHostRegistryStatus(): HostRegistryStatus { + return this.hostRegistryStatus; + } + + subscribeHostList(listener: () => void): () => void { + this.hostListListeners.add(listener); + return () => { + this.hostListListeners.delete(listener); + }; + } + + getHostListVersion(): number { + return this.hostListVersion; + } + + isHostRegistryLoaded(): boolean { + return this.hostRegistryLoaded; + } + + boot(): void { + if (this.bootStarted) { + return; + } + this.bootStarted = true; + void this.runBoot(); + } + + private async runBoot(): Promise { + const override = readConfiguredLocalDaemonOverride(); + await this.loadFromStorage(); + this.markHostRegistryLoaded(); + + let isE2E: string | null = null; + try { + isE2E = await this.storage.getItem(E2E_STORAGE_KEY); + } catch { + return; + } + if (isE2E) { + return; + } + + if (shouldUseDesktopDaemon()) { + return; + } + + const initialHint = this.deps.readInitialConnectionHint + ? this.deps.readInitialConnectionHint() + : readInitialDaemonConnectionHint(); + if (initialHint) { + const bootstrapped = await this.bootstrapInitialConnectionHint(initialHint); + if (bootstrapped) { + return; + } + } + + if (override) { + this.bootstrapConfiguredOverride(override); + } else { + await this.bootstrapDefaultLocalhost(); + } + } + + private async loadFromStorage(): Promise { + let shouldPersistHosts = false; + try { + const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY); + if (!stored) { + return; + } + const parsed = JSON.parse(stored) as unknown; + if (!Array.isArray(parsed)) { + return; + } + const normalizedProfiles = parsed + .map((entry) => normalizeStoredHostProfile(entry)) + .filter((entry): entry is HostProfile => entry !== null); + const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); + this.hosts = profiles; + this.syncHosts(profiles); + if (profiles.length !== normalizedProfiles.length) { + shouldPersistHosts = true; + } + } catch (error) { + console.error("[HostRuntime] Failed to load host registry from storage", error); + } finally { + this.hostRegistryStatus = "ready"; + this.emitHostList(); + if (shouldPersistHosts) { + void this.persistHosts(); + } + } + } + + private markHostRegistryLoaded(): void { + if (this.hostRegistryLoaded) { + return; + } + this.hostRegistryLoaded = true; + this.emitHostList(); + } + + private async bootstrapDefaultLocalhost(): Promise { + const connection = connectionFromListen(LOCALHOST_FALLBACK_ENDPOINT); + if (!connection || registryHasConnection(this.hosts, connection)) { + return; + } + + try { + await this.probeAndUpsertConnection({ + connection, + timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, + }); + } catch (error) { + console.warn("[HostRuntime] bootstrap probe failed", { + endpoint: LOCALHOST_FALLBACK_ENDPOINT, + error, + }); + } + } + + private bootstrapConfiguredOverride(endpoint: string): void { + const connection = connectionFromListen(endpoint); + if (!connection) { + return; + } + if (registryHasConnection(this.hosts, connection)) { + return; + } + if (this.configuredOverrideBootstrapInFlight) { + return; + } + + const bootstrap = this.runConfiguredOverrideBootstrap(endpoint, connection).finally(() => { + if (this.configuredOverrideBootstrapInFlight === bootstrap) { + this.configuredOverrideBootstrapInFlight = null; + } + }); + this.configuredOverrideBootstrapInFlight = bootstrap; + } + + private async runConfiguredOverrideBootstrap( + endpoint: string, + connection: HostConnection, + ): Promise { + let attempt = 0; + while (!registryHasConnection(this.hosts, connection)) { + attempt += 1; + try { + await this.probeAndUpsertConnection({ + connection, + timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, + }); + return; + } catch (error) { + if (attempt === 1 || attempt % 10 === 0) { + console.warn("[HostRuntime] configured bootstrap probe failed", { + endpoint, + attempt, + error, + }); + } + await delay(CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS); + } + } + } + + private async bootstrapInitialConnectionHint( + hint: InitialDaemonConnectionHint, + ): Promise { + const connection = connectionFromListen(hint.listen); + if (!connection) { + return false; + } + const connectionWithHint: HostConnection = + connection.type === "directTcp" + ? { ...connection, useTls: hint.useTls ?? connection.useTls ?? false } + : connection; + if (registryHasConnection(this.hosts, connectionWithHint)) { + return true; + } + + try { + await this.probeAndUpsertConnection({ + connection: connectionWithHint, + timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, + }); + return true; + } catch (error) { + console.warn("[HostRuntime] initial connection hint probe failed", { + listen: hint.listen, + useTls: hint.useTls, + error, + }); + return false; + } + } + + reconcileServerId(oldServerId: string, newServerId: string): void { + if (oldServerId === newServerId) { + return; + } + const controller = this.controllers.get(oldServerId); + if (!controller) { + return; + } + if (this.controllers.has(newServerId)) { + return; + } + + rekeyMap(this.controllers, oldServerId, newServerId); + controller.adoptReconciledServerId(newServerId); + + rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); + rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId); + + const listeners = this.serverListeners.get(oldServerId); + if (listeners) { + this.serverListeners.delete(oldServerId); + const merged = this.serverListeners.get(newServerId) ?? new Set<() => void>(); + for (const listener of listeners) { + merged.add(listener); + } + this.serverListeners.set(newServerId, merged); + } + + this.hosts = this.hosts.map((host) => + host.serverId === oldServerId + ? { ...host, serverId: newServerId, updatedAt: new Date().toISOString() } + : host, + ); + this.emitHostList(); + this.emit(newServerId); + void this.persistHosts(); + } + + async upsertDirectConnection(input: { + serverId: string; + endpoint: string; + useTls?: boolean; + password?: string; + label?: string; + existingClient?: DaemonClient; + }): Promise { + const endpoint = normalizeHostPort(input.endpoint); + const password = input.password?.trim(); + return this.upsertHostConnection({ + serverId: input.serverId, + label: input.label, + connection: { + id: `direct:${endpoint}`, + type: "directTcp", + endpoint, + useTls: input.useTls ?? false, + ...(password ? { password } : {}), + }, + existingClient: input.existingClient, + }); + } + + async probeAndUpsertConnection(input: { + connection: HostConnection; + label?: string; + timeoutMs?: number; + }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { + if (input.connection.type === "relay") { + throw new Error("Cannot probe a relay connection without a server id."); + } + const probeHost: HostProfile = { + serverId: "", + label: input.label ?? input.connection.id, + lifecycle: {}, + connections: [input.connection], + preferredConnectionId: input.connection.id, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + const { client, serverId, hostname } = await this.deps.connectToDaemon({ + host: probeHost, + connection: input.connection, + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }); + const profile = await this.upsertHostConnection({ + serverId, + label: input.label ?? hostname ?? undefined, + connection: input.connection, + existingClient: client, + }); + return { profile, serverId, hostname }; + } + + async probeAndUpsertDirectConnection(input: { + endpoint: string; + useTls?: boolean; + password?: string; + label?: string; + }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { + const endpoint = normalizeHostPort(input.endpoint); + const password = input.password?.trim(); + return this.probeAndUpsertConnection({ + label: input.label, + connection: { + id: `direct:${endpoint}`, + type: "directTcp", + endpoint, + useTls: input.useTls ?? false, + ...(password ? { password } : {}), + }, + }); + } + + async upsertRelayConnection(input: { + serverId: string; + relayEndpoint: string; + useTls?: boolean; + daemonPublicKeyB64: string; + label?: string; + }): Promise { + const relayEndpoint = normalizeHostPort(input.relayEndpoint); + const useTls = input.useTls ?? false; + const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); + if (!daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required"); + } + const explicitUseTls = input.useTls !== undefined; + return this.upsertHostConnection({ + serverId: input.serverId, + label: input.label, + connection: { + id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, + type: "relay", + relayEndpoint, + ...(explicitUseTls ? { useTls } : {}), + daemonPublicKeyB64, + }, + }); + } + + async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise { + // COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10. + const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); + return this.upsertRelayConnection({ + serverId: offer.serverId, + relayEndpoint: offer.relay.endpoint, + useTls, + daemonPublicKeyB64: offer.daemonPublicKeyB64, + label, + }); + } + + async upsertConnectionFromOfferUrl( + offerUrlOrFragment: string, + label?: string, + ): Promise { + const marker = "#offer="; + const idx = offerUrlOrFragment.indexOf(marker); + if (idx === -1) { + throw new Error("Missing #offer= fragment"); + } + const encoded = offerUrlOrFragment.slice(idx + marker.length).trim(); + if (!encoded) { + throw new Error("Offer payload is empty"); + } + const payload = decodeOfferFragmentPayload(encoded); + const offer = ConnectionOfferSchema.parse(payload); + return this.upsertConnectionFromOffer(offer, label); + } + + async upsertConnectionFromListen(input: { + listenAddress: string; + serverId: string; + hostname: string | null; + }): Promise { + const normalizedListenAddress = input.listenAddress.trim(); + const serverId = input.serverId.trim(); + const connection = connectionFromListen(normalizedListenAddress); + if (!connection) { + throw new Error(`Unsupported listen address: ${input.listenAddress}`); + } + if (!serverId) { + throw new Error("Desktop daemon did not return a server id."); + } + return this.upsertHostConnection({ + serverId, + label: input.hostname ?? undefined, + connection, + }); + } + + async renameHost(serverId: string, label: string): Promise { + const next = this.hosts.map((h) => + h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h, + ); + this.setHostsAndSync(next); + await this.persistHosts(); + } + + async removeHost(serverId: string): Promise { + const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId); + this.setHostsAndSync(remaining); + await this.persistHosts(); + } + + async removeConnection(serverId: string, connectionId: string): Promise { + const now = new Date().toISOString(); + const next = this.hosts + .map((daemon) => { + if (daemon.serverId !== serverId) return daemon; + const remaining = daemon.connections.filter((conn) => conn.id !== connectionId); + if (remaining.length === 0) { + return null; + } + const preferred = + daemon.preferredConnectionId === connectionId + ? (remaining[0]?.id ?? null) + : daemon.preferredConnectionId; + return { + ...daemon, + connections: remaining, + preferredConnectionId: preferred, + updatedAt: now, + } satisfies HostProfile; + }) + .filter((entry): entry is HostProfile => entry !== null); + this.setHostsAndSync(next); + await this.persistHosts(); + } + + private async upsertHostConnection(input: { + serverId: string; + label?: string; + connection: HostConnection; + existingClient?: DaemonClient; + }): Promise { + const now = new Date().toISOString(); + const next = upsertHostConnectionInProfiles({ + profiles: this.hosts, + serverId: input.serverId, + label: input.label, + connection: input.connection, + now, + }); + this.setHostsAndSync(next, { + initialConnectionByServerId: input.existingClient + ? new Map([ + [ + input.serverId, + { + connectionId: input.connection.id, + existingClient: input.existingClient, + }, + ], + ]) + : undefined, + }); + void this.persistHosts(); + return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile; + } + + private setHostsAndSync( + hosts: HostProfile[], + options?: { + initialConnectionByServerId?: Map< + string, + { connectionId: string; existingClient: DaemonClient } + >; + }, + ): void { + this.hosts = hosts; + this.syncHosts(hosts, options); + this.emitHostList(); + } + + private async persistHosts(): Promise { + try { + await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts)); + } catch (error) { + console.error("[HostRuntime] Failed to persist host registry", error); + } + } + + private emitHostList(): void { + this.hostListVersion += 1; + for (const listener of this.hostListListeners) { + listener(); + } + } + + syncHosts( + hosts: HostProfile[], + options?: { + initialConnectionByServerId?: Map< + string, + { connectionId: string; existingClient: DaemonClient } + >; + }, + ): void { + const nextIds = new Set(hosts.map((host) => host.serverId)); + for (const [serverId, controller] of this.controllers) { + if (nextIds.has(serverId)) { + continue; + } + this.controllers.delete(serverId); + this.lastConnectionStatusByServer.delete(serverId); + this.agentDirectoryBootstrapInFlight.delete(serverId); + void controller.stop(); + this.emit(serverId); + } + + for (const host of hosts) { + const initialConnection = options?.initialConnectionByServerId?.get(host.serverId); + const existing = this.controllers.get(host.serverId); + if (existing) { + void existing.updateHost(host); + if (initialConnection) { + void existing.activateConnection(initialConnection).catch(() => { + void initialConnection.existingClient.close().catch(() => undefined); + }); + } + continue; + } + const controller = new HostRuntimeController({ + host, + deps: this.deps, + onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), + }); + this.controllers.set(host.serverId, controller); + this.lastConnectionStatusByServer.set( + host.serverId, + controller.getSnapshot().connectionStatus, + ); + controller.subscribe(() => { + this.maybeAutoBootstrapAgentDirectory(host.serverId); + this.emit(host.serverId); + }); + void controller + .start( + initialConnection + ? { + initialConnection, + } + : {}, + ) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + controller.markStartupError(message); + }); + this.emit(host.serverId); + } + } + + private maybeAutoBootstrapAgentDirectory(serverId: string): void { + const controller = this.controllers.get(serverId); + if (!controller) { + this.lastConnectionStatusByServer.delete(serverId); + this.agentDirectoryBootstrapInFlight.delete(serverId); + return; + } + const snapshot = controller.getSnapshot(); + const previousStatus = this.lastConnectionStatusByServer.get(serverId); + this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus); + const didTransitionOnline = + snapshot.connectionStatus === "online" && previousStatus !== "online"; + if (didTransitionOnline) { + useSessionStore.getState().bumpHistorySyncGeneration(serverId); + // Checkout git data is push-driven; pushes emitted while disconnected are gone for + // good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active + // queries refetch now and evicted ones on their next mount. + void invalidateCheckoutGitQueriesForServer(queryClient, serverId); + invalidateServerDataQueriesAfterReconnect({ queryClient, serverId }); + // Workspace setup status is push-driven the same way, and the client + // caches "this workspace has no setup" so navigation stops re-asking. + // A reconnect is the one event that can have dropped the push that would + // have invalidated it, so re-arm those workspaces for one more ask. + useWorkspaceSetupStore.getState().clearResolvedEmpty(serverId); + void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey }); + // The cached local desktop-daemon serverId (["desktop-daemon-server-id"]) never + // refetches on its own — staleTime: Infinity, refetchOnMount/Reconnect false. If the + // local daemon restarts with a new serverId it would keep serving the stale id, + // stranding Settings on a ghost host ("Host not found"). Force a re-read on any + // reconnect so it self-heals. refetchType "all" is required: marking it stale alone + // wouldn't heal it while unobserved, since refetchOnMount is false. The query is + // global and enabled only on desktop, so gate on that. + if (shouldUseDesktopDaemon()) { + void queryClient.invalidateQueries({ + queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY, + refetchType: "all", + }); + } + } + const didTransitionOffline = + snapshot.connectionStatus !== "online" && previousStatus === "online"; + if (didTransitionOnline || didTransitionOffline) { + // The aggregated host queries (projects, schedules, artifacts) skip + // non-online hosts at fetch time, so any online flip changes their + // results. Invalidation lives here — not in component effects — so it + // cannot miss a transition that happens while no screen is mounted or + // during a mount/re-render window. + invalidateHostAggregateQueries(queryClient); + } + + // Runtime owns directory bootstrap policy, including reconnect and delayed + // session initialization races. + if (snapshot.connectionStatus !== "online") { + return; + } + if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) { + return; + } + if (this.agentDirectoryBootstrapInFlight.has(serverId)) { + return; + } + + const bootstrap = Promise.resolve() + .then(() => + this.refreshAgentDirectory({ + serverId, + subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, + }), + ) + .then(() => undefined) + .catch((error) => { + console.error("[HostRuntime] agent directory bootstrap failed", { + serverId, + error: toErrorMessage(error), + }); + }) + .finally(() => { + const inFlight = this.agentDirectoryBootstrapInFlight.get(serverId); + if (inFlight === bootstrap) { + this.agentDirectoryBootstrapInFlight.delete(serverId); + } + }); + + this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); + } + + getSnapshot(serverId: string): HostRuntimeSnapshot | null { + return this.controllers.get(serverId)?.getSnapshot() ?? null; + } + + /** Every live host controller's snapshot. Used by the resource monitor to sum + * per-connection traffic without needing to know which hosts exist. */ + getSnapshots(): HostRuntimeSnapshot[] { + return [...this.controllers.values()].map((controller) => controller.getSnapshot()); + } + + getVersion(): number { + return this.version; + } + + getClient(serverId: string): DaemonClient | null { + return this.controllers.get(serverId)?.getClient() ?? null; + } + + subscribe(serverId: string, listener: () => void): () => void { + const existing = this.serverListeners.get(serverId) ?? new Set<() => void>(); + existing.add(listener); + this.serverListeners.set(serverId, existing); + return () => { + const set = this.serverListeners.get(serverId); + if (!set) { + return; + } + set.delete(listener); + if (set.size === 0) { + this.serverListeners.delete(serverId); + } + }; + } + + subscribeAll(listener: () => void): () => void { + this.globalListeners.add(listener); + return () => { + this.globalListeners.delete(listener); + }; + } + + getEarliestOnlineHostServerId(): string | null { + let earliestServerId: string | null = null; + let earliestOnlineAt: string | null = null; + for (const host of this.hosts) { + const snapshot = this.getSnapshot(host.serverId); + if (!isHostRuntimeConnected(snapshot) || !snapshot?.lastOnlineAt) continue; + if (!earliestOnlineAt || snapshot.lastOnlineAt < earliestOnlineAt) { + earliestOnlineAt = snapshot.lastOnlineAt; + earliestServerId = host.serverId; + } + } + return earliestServerId; + } + + ensureConnectedAll(): void { + for (const controller of this.controllers.values()) { + controller.ensureConnected(); + } + } + + runProbeCycleNow(serverId?: string): Promise { + if (serverId) { + return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve(); + } + return Promise.all( + Array.from(this.controllers.values(), (controller) => controller.runProbeCycleNow()), + ).then(() => undefined); + } + + async refreshAgentDirectory(input: { + serverId: string; + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; + }): Promise<{ + agents: ReturnType["agents"]; + subscriptionId: string | null; + }> { + const controller = this.controllers.get(input.serverId); + if (!controller) { + throw new Error(`Unknown host runtime for serverId ${input.serverId}`); + } + const snapshot = controller.getSnapshot(); + const client = controller.getClient(); + if (!client || snapshot.connectionStatus !== "online") { + throw new Error(`Host ${input.serverId} is not connected`); + } + + controller.markAgentDirectorySyncLoading(); + try { + const session = useSessionStore.getState().sessions[input.serverId]; + if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { + const result = await fetchLegacyDaemonWorkspaceDirectory({ + client, + serverId: input.serverId, + subscribe: input.subscribe, + page: input.page, + }); + controller.markAgentDirectorySyncReady(); + return { + agents: result.agents, + subscriptionId: result.subscriptionId, + }; + } + + const directory = await fetchCurrentAgentDirectory({ + client, + filter: input.filter, + subscribe: input.subscribe, + page: input.page, + }); + + const { agents } = replaceFetchedAgentDirectory({ + serverId: input.serverId, + entries: directory.entries, + }); + + controller.markAgentDirectorySyncReady(); + return { + agents, + subscriptionId: directory.subscriptionId, + }; + } catch (error) { + controller.markAgentDirectorySyncError(toErrorMessage(error)); + throw error; + } + } + + refreshAllAgentDirectories(input?: { serverIds?: string[] }): void { + const targetServerIds = input?.serverIds ? new Set(input.serverIds) : null; + for (const [serverId] of this.controllers) { + if (targetServerIds && !targetServerIds.has(serverId)) { + continue; + } + void this.refreshAgentDirectory({ serverId }).catch(() => undefined); + } + } + + markAgentDirectorySyncLoading(serverId: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncLoading(); + } + + markAgentDirectorySyncReady(serverId: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncReady(); + } + + markAgentDirectorySyncError(serverId: string, error: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncError(error); + } + + markAgentDirectorySyncIdle(serverId: string): void { + this.controllers.get(serverId)?.markAgentDirectorySyncIdle(); + } + + private emit(serverId: string): void { + this.version += 1; + const listeners = this.serverListeners.get(serverId); + if (!listeners) { + for (const listener of this.globalListeners) { + listener(); + } + return; + } + for (const listener of listeners) { + listener(); + } + for (const listener of this.globalListeners) { + listener(); + } + } +} + +let singletonHostRuntimeStore: HostRuntimeStore | null = null; +const HOST_RUNTIME_STORE_GLOBAL_KEY = "__ottoHostRuntimeStore"; + +type HostRuntimeGlobal = typeof globalThis & { + [HOST_RUNTIME_STORE_GLOBAL_KEY]?: HostRuntimeStore; +}; + +export function getHostRuntimeStore(): HostRuntimeStore { + if (singletonHostRuntimeStore) { + return singletonHostRuntimeStore; + } + + const runtimeGlobal = globalThis as HostRuntimeGlobal; + if (runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY]) { + singletonHostRuntimeStore = runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] ?? null; + if (singletonHostRuntimeStore) { + return singletonHostRuntimeStore; + } + } + + singletonHostRuntimeStore = new HostRuntimeStore(); + runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] = singletonHostRuntimeStore; + return singletonHostRuntimeStore; +} + +export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId), + () => store.getSnapshot(serverId), + ); +} + +export function useHostRuntimeClient(serverId: string): DaemonClient | null { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.client ?? null, + () => store.getSnapshot(serverId)?.client ?? null, + ); +} + +export function useHostRuntimeIsConnected(serverId: string): boolean { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => isHostRuntimeConnected(store.getSnapshot(serverId)), + () => isHostRuntimeConnected(store.getSnapshot(serverId)), + ); +} + +export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting", + () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting", + ); +} + +export function useHostRuntimeConnectionStatuses( + serverIds: readonly string[], +): ReadonlyMap { + const store = getHostRuntimeStore(); + const version = useSyncExternalStore( + (onStoreChange) => store.subscribeAll(onStoreChange), + () => store.getVersion(), + () => store.getVersion(), + ); + + return useMemo(() => { + // The aggregate version is the reactivity trigger; re-read snapshots on every host tick. + void version; + return new Map( + serverIds.map( + (serverId) => + [serverId, store.getSnapshot(serverId)?.connectionStatus ?? "connecting"] as const, + ), + ); + }, [serverIds, store, version]); +} + +export function useHostRuntimeLastError(serverId: string): string | null { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.lastError ?? null, + () => store.getSnapshot(serverId)?.lastError ?? null, + ); +} + +export function useHostRuntimeAgentDirectoryStatus( + serverId: string, +): HostRuntimeAgentDirectoryStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle", + () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle", + ); +} + +export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)), + () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)), + ); +} + +export function useHosts(): HostProfile[] { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribeHostList(onStoreChange), + () => store.getHosts(), + () => store.getHosts(), + ); +} + +export function useHostRegistryStatus(): HostRegistryStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribeHostList(onStoreChange), + () => store.getHostRegistryStatus(), + () => store.getHostRegistryStatus(), + ); +} + +export function useHostRegistryLoaded(): boolean { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribeHostList(onStoreChange), + () => store.isHostRegistryLoaded(), + () => store.isHostRegistryLoaded(), + ); +} + +export interface HostMutations { + upsertDirectConnection: (input: { + serverId: string; + endpoint: string; + useTls?: boolean; + password?: string; + label?: string; + }) => Promise; + probeAndUpsertDirectConnection: (input: { + endpoint: string; + useTls?: boolean; + password?: string; + label?: string; + }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; + upsertRelayConnection: (input: { + serverId: string; + relayEndpoint: string; + useTls?: boolean; + daemonPublicKeyB64: string; + label?: string; + }) => Promise; + upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise; + upsertConnectionFromOfferUrl: ( + offerUrlOrFragment: string, + label?: string, + ) => Promise; + renameHost: (serverId: string, label: string) => Promise; + removeHost: (serverId: string) => Promise; + removeConnection: (serverId: string, connectionId: string) => Promise; +} + +export function useHostMutations(): HostMutations { + const store = getHostRuntimeStore(); + return useMemo( + () => ({ + upsertDirectConnection: (input) => store.upsertDirectConnection(input), + probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input), + upsertRelayConnection: (input) => store.upsertRelayConnection(input), + upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), + upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label), + renameHost: (serverId, label) => store.renameHost(serverId, label), + removeHost: (serverId) => store.removeHost(serverId), + removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId), + }), + [store], + ); +} diff --git a/packages/app/src/schedules/aggregated-schedules.test.ts b/packages/app/src/schedules/aggregated-schedules.test.ts new file mode 100644 index 000000000..906344744 --- /dev/null +++ b/packages/app/src/schedules/aggregated-schedules.test.ts @@ -0,0 +1,130 @@ +import type { ScheduleSummary } from "@otto-code/protocol/schedule/types"; +import { describe, expect, it } from "vitest"; +import { + fetchAggregatedSchedules, + type ScheduleRuntime, + type ScheduleRuntimeSnapshot, +} from "./aggregated-schedules"; + +function makeSchedule(overrides: Partial = {}): ScheduleSummary { + return { + id: "schedule-1", + name: "Nightly", + prompt: "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } }, + status: "active", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + nextRunAt: "2026-07-02T01:00:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + ...overrides, + }; +} + +function makeRuntime(input: { + snapshots: Record; + schedules?: Record; +}): ScheduleRuntime { + return { + getSnapshot: (serverId) => input.snapshots[serverId] ?? null, + getClient: (serverId) => { + const schedules = input.schedules?.[serverId]; + if (!schedules) { + return null; + } + return { + scheduleList: async () => ({ requestId: "test-request", schedules, error: null }), + }; + }, + }; +} + +describe("fetchAggregatedSchedules load state", () => { + it("does not report loaded empty while known hosts are still connecting", async () => { + const result = await fetchAggregatedSchedules({ + hosts: [ + { serverId: "host-a", serverName: "Host A" }, + { serverId: "host-b", serverName: "Host B" }, + ], + runtime: makeRuntime({ + snapshots: { + "host-a": { connectionStatus: "connecting" }, + "host-b": { connectionStatus: "connecting" }, + }, + }), + }); + + expect(result.status).not.toBe("loaded"); + expect(result).toEqual({ status: "connecting" }); + }); + + it("reports loaded empty after all reachable hosts answer with no schedules", async () => { + const result = await fetchAggregatedSchedules({ + hosts: [ + { serverId: "host-a", serverName: "Host A" }, + { serverId: "host-b", serverName: "Host B" }, + ], + runtime: makeRuntime({ + snapshots: { + "host-a": { connectionStatus: "online" }, + "host-b": { connectionStatus: "online" }, + }, + schedules: { + "host-a": [], + "host-b": [], + }, + }), + }); + + expect(result).toEqual({ status: "loaded", data: [], hostErrors: [] }); + }); + + it("does not report loaded empty while another known host is still connecting", async () => { + const result = await fetchAggregatedSchedules({ + hosts: [ + { serverId: "host-a", serverName: "Host A" }, + { serverId: "host-b", serverName: "Host B" }, + ], + runtime: makeRuntime({ + snapshots: { + "host-a": { connectionStatus: "online" }, + "host-b": { connectionStatus: "connecting" }, + }, + schedules: { + "host-a": [], + }, + }), + }); + + expect(result).toEqual({ status: "connecting" }); + }); + + it("loads reachable host data when another known host is still connecting", async () => { + const schedule = makeSchedule(); + const result = await fetchAggregatedSchedules({ + hosts: [ + { serverId: "host-a", serverName: "Host A" }, + { serverId: "host-b", serverName: "Host B" }, + ], + runtime: makeRuntime({ + snapshots: { + "host-a": { connectionStatus: "online" }, + "host-b": { connectionStatus: "connecting" }, + }, + schedules: { + "host-a": [schedule], + }, + }), + }); + + expect(result).toEqual({ + status: "loaded", + data: [{ ...schedule, serverId: "host-a", serverName: "Host A" }], + hostErrors: [], + }); + }); +}); diff --git a/packages/app/src/schedules/aggregated-schedules.ts b/packages/app/src/schedules/aggregated-schedules.ts new file mode 100644 index 000000000..4a78289fe --- /dev/null +++ b/packages/app/src/schedules/aggregated-schedules.ts @@ -0,0 +1,133 @@ +import type { DaemonClient } from "@otto-code/client/internal/daemon-client"; +import type { ScheduleSummary } from "@otto-code/protocol/schedule/types"; +import { toErrorMessage } from "@/utils/error-messages"; + +export const schedulesQueryBaseKey = ["schedules"] as const; + +export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules"; + +export interface ScheduleHostInput { + serverId: string; + serverName: string; +} + +export interface ScheduleRuntimeSnapshot { + connectionStatus: string; +} + +export interface ScheduleRuntime { + getClient(serverId: string): Pick | null; + getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined; +} + +/** A schedule tagged with the host it came from, so the flat list can render a + * per-row host label and scope mutations without host sections. */ +export interface AggregatedSchedule extends ScheduleSummary { + serverId: string; + serverName: string; +} + +export interface ScheduleHostError { + serverId: string; + serverName: string; + message: string; +} + +export interface FetchAggregatedSchedulesConnectingResult { + status: "connecting"; +} + +export interface FetchAggregatedSchedulesResult { + status: "loaded"; + data: AggregatedSchedule[]; + hostErrors: ScheduleHostError[]; +} + +export type FetchAggregatedSchedulesState = + | FetchAggregatedSchedulesConnectingResult + | FetchAggregatedSchedulesResult; + +export type AggregateLoadState = + | { status: "connecting" } + | { status: "loading" } + | { status: "loaded"; data: T[] }; + +export interface FetchAggregatedSchedulesInput { + hosts: readonly ScheduleHostInput[]; + runtime: ScheduleRuntime; +} + +/** + * Fetch schedules across connected hosts and merge them into one flat list. + * Connectivity is checked here at execution time, so explicit query refreshes + * pick up the currently connected host set. + * + * Offline hosts are skipped. A connected host that fails contributes to + * `hostErrors` (surfaced as a banner) while the rest still render; only when + * every connected host fails do we throw so the screen shows a full error. + */ +export async function fetchAggregatedSchedules( + input: FetchAggregatedSchedulesInput, +): Promise { + const hasSettlingHost = input.hosts.some((host) => + isScheduleHostConnectionSettling(input.runtime.getSnapshot(host.serverId)), + ); + const hasAskableHost = input.hosts.some((host) => { + const snapshot = input.runtime.getSnapshot(host.serverId); + return snapshot?.connectionStatus === "online" && input.runtime.getClient(host.serverId); + }); + + if (!hasAskableHost && hasSettlingHost) { + return { status: "connecting" }; + } + + const schedules: AggregatedSchedule[] = []; + const hostErrors: ScheduleHostError[] = []; + let connectedAttempts = 0; + + await Promise.all( + input.hosts.map(async (host) => { + const snapshot = input.runtime.getSnapshot(host.serverId); + const isOnline = snapshot?.connectionStatus === "online"; + const client = input.runtime.getClient(host.serverId); + if (!client || !isOnline) { + return; + } + connectedAttempts += 1; + try { + const payload = await client.scheduleList(); + if (payload.error) { + throw new Error(payload.error); + } + for (const schedule of payload.schedules) { + schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName }); + } + } catch (error) { + hostErrors.push({ + serverId: host.serverId, + serverName: host.serverName, + message: toErrorMessage(error), + }); + } + }), + ); + + if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) { + throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE); + } + + if (schedules.length === 0 && hasSettlingHost) { + return { status: "connecting" }; + } + + return { status: "loaded", data: schedules, hostErrors }; +} + +function isScheduleHostConnectionSettling( + snapshot: ScheduleRuntimeSnapshot | null | undefined, +): boolean { + if (!snapshot) { + return true; + } + return snapshot.connectionStatus === "connecting" || snapshot.connectionStatus === "idle"; +} diff --git a/packages/app/src/schedules/schedule-cadence-options.test.ts b/packages/app/src/schedules/schedule-cadence-options.test.ts new file mode 100644 index 000000000..2c7b83bd5 --- /dev/null +++ b/packages/app/src/schedules/schedule-cadence-options.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + CADENCE_PRESET_OPTIONS, + normalizeScheduleFormCadence, + resolveCronPresetId, +} from "./schedule-cadence-options"; + +describe("schedule cadence form options", () => { + it("offers the approved cron preset vocabulary", () => { + expect(CADENCE_PRESET_OPTIONS.map((option) => option.label)).toEqual([ + "Every minute", + "Every hour", + "Daily 9:00", + "Weekdays 9:00", + "Mondays 9:00", + ]); + }); + + it("maps interval cadences to cron cadences for the form", () => { + expect( + normalizeScheduleFormCadence({ type: "every", everyMs: 60_000 }, "Europe/Madrid"), + ).toEqual({ + type: "cron", + expression: "* * * * *", + timezone: "Europe/Madrid", + }); + expect( + normalizeScheduleFormCadence({ type: "every", everyMs: 60 * 60_000 }, "Europe/Madrid"), + ).toEqual({ + type: "cron", + expression: "0 * * * *", + timezone: "Europe/Madrid", + }); + expect( + normalizeScheduleFormCadence({ type: "every", everyMs: 24 * 60 * 60_000 }, "Europe/Madrid"), + ).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "Europe/Madrid", + }); + }); + + it("maps unsupported intervals to the closest custom cron expression", () => { + const cadence = normalizeScheduleFormCadence( + { type: "every", everyMs: 5 * 60_000 }, + "Europe/Madrid", + ); + + expect(cadence).toEqual({ + type: "cron", + expression: "*/5 * * * *", + timezone: "Europe/Madrid", + }); + expect(resolveCronPresetId(cadence)).toBe("custom"); + }); +}); diff --git a/packages/app/src/schedules/schedule-cadence-options.ts b/packages/app/src/schedules/schedule-cadence-options.ts new file mode 100644 index 000000000..d729f6604 --- /dev/null +++ b/packages/app/src/schedules/schedule-cadence-options.ts @@ -0,0 +1,62 @@ +import type { ScheduleCadence } from "@otto-code/protocol/schedule/types"; +import { everyMsToParts } from "@/utils/schedule-format"; + +type CronCadence = Extract; + +export interface CadencePresetOption { + id: string; + label: string; + expression: string; +} + +export const CUSTOM_CRON_PRESET_ID = "custom"; + +export const CADENCE_PRESET_OPTIONS: CadencePresetOption[] = [ + { id: "every-minute", label: "Every minute", expression: "* * * * *" }, + { id: "every-hour", label: "Every hour", expression: "0 * * * *" }, + { id: "daily-9", label: "Daily 9:00", expression: "0 9 * * *" }, + { id: "weekdays-9", label: "Weekdays 9:00", expression: "0 9 * * 1-5" }, + { id: "mondays-9", label: "Mondays 9:00", expression: "0 9 * * 1" }, +]; + +export function resolveCronPresetId(cadence: CronCadence): string { + const expression = cadence.expression.trim(); + return ( + CADENCE_PRESET_OPTIONS.find((option) => option.expression === expression)?.id ?? + CUSTOM_CRON_PRESET_ID + ); +} + +export function resolveCronPresetDisplay(cadence: CronCadence): { label: string } { + return { + label: + CADENCE_PRESET_OPTIONS.find((option) => option.id === resolveCronPresetId(cadence))?.label ?? + "Custom cron", + }; +} + +export function normalizeScheduleFormCadence( + cadence: ScheduleCadence, + timezone: string, +): CronCadence { + if (cadence.type === "cron") { + return { ...cadence, timezone: cadence.timezone ?? timezone }; + } + + return { + type: "cron", + expression: everyMsToCronExpression(cadence.everyMs), + timezone, + }; +} + +function everyMsToCronExpression(everyMs: number): string { + const { value, unit } = everyMsToParts(everyMs); + if (unit === "minutes") { + return value === 1 ? "* * * * *" : `*/${Math.min(value, 59)} * * * *`; + } + if (unit === "hours") { + return value === 1 ? "0 * * * *" : `0 */${Math.min(value, 23)} * * *`; + } + return value === 1 ? "0 9 * * *" : `0 9 */${Math.min(value, 31)} * *`; +} diff --git a/packages/app/src/schedules/schedule-derivation.test.ts b/packages/app/src/schedules/schedule-derivation.test.ts new file mode 100644 index 000000000..fdb281c26 --- /dev/null +++ b/packages/app/src/schedules/schedule-derivation.test.ts @@ -0,0 +1,196 @@ +import type { ScheduleSummary } from "@otto-code/protocol/schedule/types"; +import { describe, expect, it } from "vitest"; +import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation"; + +const NOW = Date.parse("2026-07-02T00:00:00.000Z"); +const AGENT_ID = "00000000-0000-4000-8000-000000000000"; + +function makeSchedule(overrides: Partial): ScheduleSummary { + return { + id: "schedule-1", + name: "Nightly", + prompt: "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } }, + status: "active", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + nextRunAt: "2026-07-02T01:00:00.000Z", + lastRunAt: null, + lastRunStatus: null, + lastRunError: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + ...overrides, + }; +} + +function resolve( + schedule: ScheduleSummary, + options?: { + agents?: Array<[string, ScheduleTargetAgent]>; + projects?: Array<[string, string]>; + agentDataLoaded?: boolean; + }, +) { + return resolveSchedule({ + schedule, + serverId: "host-1", + now: NOW, + agentsByKey: new Map(options?.agents ?? []), + projectNameByCwd: new Map(options?.projects ?? []), + agentDataLoaded: options?.agentDataLoaded ?? true, + }); +} + +describe("resolveSchedule state", () => { + it("keeps active and paused schedules runnable", () => { + expect(resolve(makeSchedule({ status: "active" })).state).toBe("active"); + expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused"); + expect(scheduleBucket("active")).toBe("runnable"); + expect(scheduleBucket("paused")).toBe("runnable"); + }); + + it("treats a past expiresAt as expired regardless of status", () => { + const result = resolve( + makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }), + ); + expect(result.state).toBe("expired"); + expect(result.bucket).toBe("ended"); + }); + + it("ignores an unparseable expiresAt", () => { + expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active"); + }); + + it("derives finished only from completed-and-not-expired", () => { + expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished"); + expect( + resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state, + ).toBe("expired"); + }); + + it("marks an agent target gone when the client has no such agent", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + expect(resolve(schedule).state).toBe("targetGone"); + expect(resolve(schedule).bucket).toBe("ended"); + }); + + it("does not claim gone before the agent directory has loaded", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active"); + }); + + it("prefers target-gone over the raw paused/completed status for a live agent target", () => { + const paused = makeSchedule({ + status: "paused", + target: { type: "agent", agentId: AGENT_ID }, + }); + expect(resolve(paused).state).toBe("targetGone"); + }); + + it("never claims a new-agent cwd is gone", () => { + expect(resolve(makeSchedule({ status: "active" })).state).toBe("active"); + }); + + it("surfaces a failed last run as its own state/bucket regardless of active/paused status", () => { + const active = resolve(makeSchedule({ status: "active", lastRunStatus: "failed" })); + expect(active.state).toBe("failed"); + expect(active.bucket).toBe("failed"); + + const paused = resolve(makeSchedule({ status: "paused", lastRunStatus: "failed" })); + expect(paused.state).toBe("failed"); + expect(paused.bucket).toBe("failed"); + }); + + it("does not report failed once a schedule is expired, finished, or its target is gone", () => { + expect( + resolve( + makeSchedule({ + status: "active", + lastRunStatus: "failed", + expiresAt: "2026-07-01T00:00:00.000Z", + }), + ).state, + ).toBe("expired"); + + expect(resolve(makeSchedule({ status: "completed", lastRunStatus: "failed" })).state).toBe( + "finished", + ); + + const targetGone = makeSchedule({ + status: "active", + lastRunStatus: "failed", + target: { type: "agent", agentId: AGENT_ID }, + }); + expect(resolve(targetGone).state).toBe("targetGone"); + }); + + it("a succeeded last run does not mark the schedule failed", () => { + expect(resolve(makeSchedule({ status: "active", lastRunStatus: "succeeded" })).state).toBe( + "active", + ); + }); +}); + +describe("resolveSchedule cwd", () => { + it("resolves cwd from the stored config for new-agent targets", () => { + const schedule = makeSchedule({ + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } }, + }); + expect(resolve(schedule).cwd).toBe("/tmp/project"); + }); + + it("resolves cwd from the matched agent for agent targets", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + const result = resolve(schedule, { + agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude", cwd: "/tmp/x" }]], + }); + expect(result.cwd).toBe("/tmp/x"); + }); + + it("is null when the agent target cannot be resolved", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + expect(resolve(schedule).cwd).toBeNull(); + }); +}); + +describe("resolveSchedule target line", () => { + it("names an agent target by its client title and provider", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + const result = resolve(schedule, { + agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude", cwd: "/tmp/x" }]], + }); + expect(result.target).toEqual({ label: "Fix build", provider: "claude" }); + expect(result.state).toBe("active"); + }); + + it("falls back to Untitled agent when the agent has no title", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + const result = resolve(schedule, { + agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex", cwd: "/tmp/x" }]], + }); + expect(result.target.label).toBe("Untitled agent"); + }); + + it("labels a gone agent target as unavailable with no glyph", () => { + const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } }); + expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null }); + }); + + it("names a new-agent cwd by matched project, else the shortened path", () => { + const matched = makeSchedule({ + target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } }, + }); + expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({ + label: "My Project", + provider: "codex", + }); + + const unmatched = makeSchedule({ + target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } }, + }); + expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" }); + }); +}); diff --git a/packages/app/src/schedules/schedule-derivation.ts b/packages/app/src/schedules/schedule-derivation.ts new file mode 100644 index 000000000..56f78c8b0 --- /dev/null +++ b/packages/app/src/schedules/schedule-derivation.ts @@ -0,0 +1,136 @@ +import type { ScheduleSummary } from "@otto-code/protocol/schedule/types"; +import { describeScheduleCwd } from "@/schedules/schedule-project-targets"; + +// "active"/"paused"/"failed" mirror stored schedule state; the rest are +// computed truths the daemon does not spell out in a single field. +export type ScheduleDerivedState = + | "active" + | "paused" + | "failed" + | "expired" + | "finished" + | "targetGone"; + +export type ScheduleBucket = "runnable" | "failed" | "ended"; + +export interface ScheduleTargetAgent { + title: string | null; + provider: string | null; + cwd: string | null; +} + +export interface ScheduleTargetResolution { + /** The target line: agent title, project name, or the shortened cwd. */ + label: string; + /** Provider glyph for the row, when known. */ + provider: string | null; +} + +export interface ResolvedSchedule { + state: ScheduleDerivedState; + bucket: ScheduleBucket; + target: ScheduleTargetResolution; + /** The schedule's effective project root, when known — used for project filtering. */ + cwd: string | null; +} + +export interface ResolveScheduleInput { + schedule: ScheduleSummary; + serverId: string; + now: number; + /** Client agent directory keyed by `${serverId}:${agentId}`. */ + agentsByKey: ReadonlyMap; + /** Known project roots keyed by `${serverId}:${cwd}`. */ + projectNameByCwd: ReadonlyMap; + /** + * Whether the agent directory has finished its first load. While false we do + * not claim an agent target is gone — absence would just be a cold cache. + */ + agentDataLoaded: boolean; +} + +function agentKey(serverId: string, agentId: string): string { + return `${serverId}:${agentId}`; +} + +function isExpired(schedule: ScheduleSummary, now: number): boolean { + if (!schedule.expiresAt) { + return false; + } + const expiresAt = Date.parse(schedule.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= now; +} + +function isAgentTargetGone(input: ResolveScheduleInput): boolean { + const { schedule, serverId, agentsByKey, agentDataLoaded } = input; + if (schedule.target.type !== "agent" || !agentDataLoaded) { + return false; + } + return !agentsByKey.has(agentKey(serverId, schedule.target.agentId)); +} + +function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution { + const { schedule, serverId, agentsByKey, projectNameByCwd } = input; + if (schedule.target.type === "agent") { + const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId)); + if (agent) { + return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider }; + } + return { label: "Agent unavailable", provider: null }; + } + return { + label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }), + provider: schedule.target.config.provider, + }; +} + +/** The schedule's effective project root: the target agent's cwd for + * agent-targeted schedules, or the stored cwd for new-agent schedules. */ +function resolveCwd(input: ResolveScheduleInput): string | null { + const { schedule, serverId, agentsByKey } = input; + if (schedule.target.type === "agent") { + return agentsByKey.get(agentKey(serverId, schedule.target.agentId))?.cwd ?? null; + } + return schedule.target.config.cwd; +} + +// One badge, one truth. Order matters: expiry and a missing target are more +// informative than the raw "completed"/"paused" status, so they win. A failed +// last run outranks "paused"/"active" too — it's actionable regardless of +// whether the schedule is currently running or paused. +function deriveState(input: ResolveScheduleInput): ScheduleDerivedState { + const { schedule, now } = input; + if (isExpired(schedule, now)) { + return "expired"; + } + if (isAgentTargetGone(input)) { + return "targetGone"; + } + if (schedule.status === "completed") { + return "finished"; + } + if (schedule.lastRunStatus === "failed") { + return "failed"; + } + if (schedule.status === "paused") { + return "paused"; + } + return "active"; +} + +export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket { + if (state === "failed") { + return "failed"; + } + return state === "active" || state === "paused" ? "runnable" : "ended"; +} + +export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule { + const state = deriveState(input); + return { + state, + bucket: scheduleBucket(state), + target: resolveTarget(input), + cwd: resolveCwd(input), + }; +} diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts new file mode 100644 index 000000000..a979c6dd0 --- /dev/null +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -0,0 +1,631 @@ +import type { + AgentMode, + AgentModelDefinition, + ProviderSnapshotEntry, +} from "@otto-code/protocol/agent-types"; +import type { ScheduleSummary } from "@otto-code/protocol/schedule/types"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; +import { describe, expect, it } from "vitest"; +import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets"; +import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model"; + +type TestSchedule = ScheduleSummary & { serverId: string; serverName: string }; + +const HOSTS = [ + { serverId: "host-a", label: "Host A", supportsWorkspaceMultiplicity: true }, + { serverId: "host-b", label: "Host B", supportsWorkspaceMultiplicity: true }, +] as const; + +const MOCK_MODES: AgentMode[] = [{ id: "load-test", label: "Load test" }]; + +const HOST_A_MODELS: AgentModelDefinition[] = [ + { + provider: "mock", + id: "model-a", + label: "Model A", + isDefault: true, + }, +]; + +const HOST_B_MODELS: AgentModelDefinition[] = [ + { + provider: "mock", + id: "model-b", + label: "Model B", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + }, +]; + +const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS]; + +function target(input: { + serverId: string; + projectKey: string; + projectName: string; + cwd: string; + isGit?: boolean; +}): ScheduleProjectTarget { + return { + optionId: buildProjectOptionId(input.serverId, input.projectKey), + serverId: input.serverId, + serverName: input.serverId === "host-a" ? "Host A" : "Host B", + projectKey: input.projectKey, + projectName: input.projectName, + cwd: input.cwd, + isGit: input.isGit ?? true, + }; +} + +const PROJECT_TARGETS = [ + target({ + serverId: "host-a", + projectKey: "project-a", + projectName: "Project A", + cwd: "/repo/a", + }), + target({ + serverId: "host-b", + projectKey: "project-b", + projectName: "Project B", + cwd: "/repo/b", + }), +]; + +function scheduleOnHost(input: { + serverId: string; + serverName: string; + cwd: string; + model: string; + cadence?: ScheduleSummary["cadence"]; +}): TestSchedule { + return { + id: `schedule-${input.serverId}`, + serverId: input.serverId, + serverName: input.serverName, + name: "Nightly", + prompt: "Run the schedule", + cadence: input.cadence ?? { type: "cron", expression: "0 9 * * *" }, + target: { + type: "new-agent", + config: { + provider: "mock", + cwd: input.cwd, + model: input.model, + modeId: "load-test", + thinkingOptionId: "high", + archiveOnFinish: false, + isolation: "worktree", + }, + }, + status: "active", + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + nextRunAt: "2026-07-02T00:00:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: 3, + }; +} + +function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } { + return { + entries: [ + { + provider: "mock", + label: "Mock", + status: "ready", + enabled: true, + fetchedAt: "2026-07-01T00:00:00.000Z", + models, + modes: MOCK_MODES, + defaultModeId: "load-test", + }, + ], + }; +} + +function open(snapshot: Omit) { + return openScheduleForm({ + ...snapshot, + hosts: HOSTS, + }); +} + +function openWithHosts(snapshot: ScheduleFormSnapshot) { + return openScheduleForm(snapshot); +} + +function applyPreferences(form: ReturnType, preferences: FormPreferences) { + form.applyPreferences(preferences); +} + +describe("schedule form model", () => { + it("opens edit from the schedule host snapshot and completes that host resolution", () => { + const previous = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-a", + serverName: "Host A", + cwd: "/repo/a", + model: "model-a", + }), + defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + previous.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS)); + previous.close(); + + const form = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-b", + serverName: "Host B", + cwd: "/repo/b", + model: "model-b", + }), + defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + + expect(form.getState()).toMatchObject({ + mode: "edit", + selectedServerId: "host-b", + workingDir: "/repo/b", + selectedProvider: "mock", + selectedModel: "model-b", + selectedMode: "load-test", + selectedThinkingOptionId: "high", + projectDisplay: { label: "Project B" }, + selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"), + archiveOnFinish: false, + isolation: "worktree", + effectiveIsolation: "worktree", + providerResolutionByServerId: { "host-b": "pending" }, + providerSnapshotRequest: { + serverId: "host-b", + cwd: "/repo/b", + }, + }); + + form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS)); + + expect(form.getState()).toMatchObject({ + selectedServerId: "host-b", + selectedModelDisplay: { label: "Model B" }, + selectedThinkingDisplay: { label: "High" }, + providerResolutionByServerId: { "host-b": "complete" }, + providerSnapshotRequest: null, + }); + }); + + it("opens create pristine after an edit instance closes", () => { + const edit = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-b", + serverName: "Host B", + cwd: "/repo/b", + model: "model-b", + }), + defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + edit.close(); + + const create = open({ + mode: "create", + defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + + expect(create.getState()).toMatchObject({ + mode: "create", + selectedServerId: "host-a", + workingDir: "", + selectedProvider: null, + selectedModel: "", + selectedMode: "", + selectedThinkingOptionId: "", + projectDisplay: null, + selectedProjectOptionId: "", + selectedModelDisplay: null, + selectedThinkingDisplay: null, + archiveOnFinish: true, + isolation: "local", + effectiveIsolation: "local", + // Create immediately requests the host-scoped snapshot so models are + // offered before a project is picked. + providerResolutionByServerId: { "host-a": "pending" }, + providerSnapshotRequest: { serverId: "host-a", cwd: "" }, + }); + }); + + it("offers project and model immediately; capability fields follow model state", () => { + const form = open({ + mode: "create", + defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + + // No stepped disclosure: project and model show from the start, isolation + // waits for a git project, effort waits for a model with effort options. + expect(form.getState().disclosure).toEqual({ + showProjectField: true, + showModelField: true, + showThinkingField: false, + showIsolationField: false, + showArchiveOnFinishField: true, + }); + + form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" }); + + expect(form.getState().disclosure).toEqual({ + showProjectField: true, + showModelField: true, + showThinkingField: false, + showIsolationField: true, + showArchiveOnFinishField: true, + }); + + form.applyProviderSnapshot("host-a", providerSnapshot(HOST_B_MODELS)); + form.setModel("mock", "model-b"); + + expect(form.getState().disclosure).toEqual({ + showProjectField: true, + showModelField: true, + showThinkingField: true, + showIsolationField: true, + showArchiveOnFinishField: true, + }); + }); + + it("keeps the selected model across same-host project changes", () => { + const projectC = target({ + serverId: "host-a", + projectKey: "project-c", + projectName: "Project C", + cwd: "/repo/c", + }); + const form = open({ + mode: "create", + defaults: { + serverId: "host-a", + projectTargets: [...PROJECT_TARGETS, projectC], + preferences: {}, + }, + }); + form.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS)); + form.setModel("mock", "model-a"); + + form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" }); + + expect(form.getState()).toMatchObject({ + workingDir: "/repo/a", + selectedProvider: "mock", + selectedModel: "model-a", + providerSnapshotRequest: { serverId: "host-a", cwd: "/repo/a" }, + }); + + form.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS)); + form.setProject(projectC.optionId, { label: "Project C" }); + + expect(form.getState()).toMatchObject({ + workingDir: "/repo/c", + selectedProvider: "mock", + selectedModel: "model-a", + providerSnapshotRequest: { serverId: "host-a", cwd: "/repo/c" }, + }); + }); + + it("hides isolation unless the selected project can create a worktree", () => { + const nonGitTarget = target({ + serverId: "host-a", + projectKey: "plain-project", + projectName: "Plain Project", + cwd: "/tmp/plain", + isGit: false, + }); + const nonGit = open({ + mode: "create", + defaults: { serverId: "host-a", projectTargets: [nonGitTarget], preferences: {} }, + }); + + nonGit.setProject(nonGitTarget.optionId, { label: "Plain Project" }); + + expect(nonGit.getState().disclosure).toMatchObject({ + showIsolationField: false, + showArchiveOnFinishField: true, + }); + + const gitTarget = target({ + serverId: "host-a", + projectKey: "git-project", + projectName: "Git Project", + cwd: "/tmp/git", + isGit: true, + }); + const unsupportedHost = openWithHosts({ + mode: "create", + hosts: [{ serverId: "host-a", label: "Host A" }], + defaults: { serverId: "host-a", projectTargets: [gitTarget], preferences: {} }, + }); + + unsupportedHost.setProject(gitTarget.optionId, { label: "Git Project" }); + + expect(unsupportedHost.getState().disclosure).toMatchObject({ + showIsolationField: false, + showArchiveOnFinishField: false, + }); + expect( + Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitIsolation"), + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(unsupportedHost.getState(), "submitArchiveOnFinish"), + ).toBe(true); + expect( + (unsupportedHost.getState() as unknown as { submitIsolation?: string }).submitIsolation, + ).toBeUndefined(); + expect( + (unsupportedHost.getState() as unknown as { submitArchiveOnFinish?: boolean }) + .submitArchiveOnFinish, + ).toBeUndefined(); + }); + + it("preserves stored worktree isolation until host resolution proves it unavailable", () => { + const nonGitTarget = target({ + serverId: "host-b", + projectKey: "plain-project", + projectName: "Plain Project", + cwd: "/repo/b", + isGit: false, + }); + const form = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-b", + serverName: "Host B", + cwd: "/repo/b", + model: "model-b", + }), + defaults: { serverId: null, projectTargets: [nonGitTarget], preferences: {} }, + }); + + expect(form.getState()).toMatchObject({ + isolation: "worktree", + effectiveIsolation: "worktree", + canUseWorktreeIsolation: false, + providerResolutionByServerId: { "host-b": "pending" }, + }); + + form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS)); + + expect(form.getState()).toMatchObject({ + isolation: "worktree", + effectiveIsolation: "local", + canUseWorktreeIsolation: false, + providerResolutionByServerId: { "host-b": "complete" }, + }); + }); + + it("normalizes interval cadences to cron cadences when opening the form", () => { + const form = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-a", + serverName: "Host A", + cwd: "/repo/a", + model: "model-a", + cadence: { type: "every", everyMs: 60_000 }, + }), + defaults: { + serverId: null, + projectTargets: PROJECT_TARGETS, + preferences: {}, + timezone: "Europe/Madrid", + }, + }); + + expect(form.getState().cadence).toEqual({ + type: "cron", + expression: "* * * * *", + timezone: "Europe/Madrid", + }); + }); + + it("preserves an edited schedule's interval cadence until the cadence changes", () => { + const originalCadence = { type: "every" as const, everyMs: 90 * 60_000 }; + const form = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-a", + serverName: "Host A", + cwd: "/repo/a", + model: "model-a", + cadence: originalCadence, + }), + defaults: { + serverId: null, + projectTargets: PROJECT_TARGETS, + preferences: {}, + timezone: "Europe/Madrid", + }, + }); + + expect(form.getState().cadence).toEqual({ + type: "cron", + expression: "*/59 * * * *", + timezone: "Europe/Madrid", + }); + + form.setName("Renamed without touching cadence"); + + expect(form.getState().submitCadence).toEqual(originalCadence); + + form.setCadence({ + type: "cron", + expression: "0 9 * * *", + timezone: "Europe/Madrid", + }); + + expect(form.getState().submitCadence).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "Europe/Madrid", + }); + }); + + it("clears provider selection while resolving a different project", () => { + const form = open({ + mode: "create", + defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + form.setPrompt("Run on a selected project."); + form.setProject(buildProjectOptionId("host-a", "project-a"), { label: "Project A" }); + form.applyProviderSnapshot("host-a", providerSnapshot(HOST_A_MODELS)); + form.setModel("mock", "model-a"); + + expect(form.getState()).toMatchObject({ + selectedServerId: "host-a", + workingDir: "/repo/a", + selectedProvider: "mock", + selectedModel: "model-a", + canSubmit: true, + }); + + form.setProject(buildProjectOptionId("host-b", "project-b"), { label: "Project B" }); + + expect(form.getState()).toMatchObject({ + selectedServerId: "host-b", + workingDir: "/repo/b", + selectedProvider: null, + selectedModel: "", + modelSelectorProviders: [], + canSubmit: false, + providerResolutionByServerId: { "host-a": "complete", "host-b": "pending" }, + providerSnapshotRequest: { serverId: "host-b", cwd: "/repo/b" }, + }); + + form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS)); + + expect(form.getState()).toMatchObject({ + selectedServerId: "host-b", + selectedProvider: null, + selectedModel: "", + providerResolutionByServerId: { "host-a": "complete", "host-b": "complete" }, + providerSnapshotRequest: null, + modelSelectorProviders: [expect.objectContaining({ id: "mock", label: "Mock" })], + }); + }); + + it("applies new project targets without resetting selected values", () => { + const projectA = PROJECT_TARGETS[0]; + const form = open({ + mode: "create", + defaults: { serverId: "host-a", projectTargets: [projectA], preferences: {} }, + }); + form.setName("Draft name"); + form.setPrompt("Draft prompt"); + form.setProject(projectA.optionId, { label: "Project A" }); + + form.applyProjectTargets([ + projectA, + target({ + serverId: "host-a", + projectKey: "project-c", + projectName: "Project C", + cwd: "/repo/c", + }), + ]); + + expect(form.getState()).toMatchObject({ + name: "Draft name", + prompt: "Draft prompt", + selectedServerId: "host-a", + workingDir: "/repo/a", + projectDisplay: { label: "Project A" }, + selectedProjectOptionId: projectA.optionId, + projectOptions: [ + { + id: projectA.optionId, + value: projectA.optionId, + label: "Project A", + testID: "schedule-project-option-project-a", + }, + { + id: buildProjectOptionId("host-a", "project-c"), + value: buildProjectOptionId("host-a", "project-c"), + label: "Project C", + testID: "schedule-project-option-project-c", + }, + ], + }); + }); + + it("hydrates late create preferences without overwriting user changes or edited schedules", () => { + const savedPreferences: FormPreferences = { + provider: "mock", + providerPreferences: { + mock: { + model: "model-b", + mode: "load-test", + thinkingByModel: { "model-b": "high" }, + }, + }, + isolation: "worktree", + }; + + const create = open({ + mode: "create", + defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + create.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS)); + + applyPreferences(create, savedPreferences); + + expect(create.getState()).toMatchObject({ + selectedProvider: "mock", + selectedModel: "model-b", + selectedMode: "load-test", + selectedThinkingOptionId: "high", + isolation: "worktree", + }); + + const userEdited = open({ + mode: "create", + defaults: { serverId: "host-a", projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + userEdited.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS)); + userEdited.setModel("mock", "model-a"); + userEdited.setIsolation("local"); + + applyPreferences(userEdited, savedPreferences); + + expect(userEdited.getState()).toMatchObject({ + selectedProvider: "mock", + selectedModel: "model-a", + isolation: "local", + }); + + const edit = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-a", + serverName: "Host A", + cwd: "/repo/a", + model: "model-a", + }), + defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + edit.applyProviderSnapshot("host-a", providerSnapshot(ALL_MODELS)); + + applyPreferences(edit, { ...savedPreferences, isolation: "local" }); + + expect(edit.getState()).toMatchObject({ + selectedProvider: "mock", + selectedModel: "model-a", + selectedMode: "load-test", + isolation: "worktree", + }); + }); +}); diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts new file mode 100644 index 000000000..69bbf3606 --- /dev/null +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -0,0 +1,1020 @@ +import type { + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "@otto-code/protocol/agent-types"; +import type { ScheduleCadence, ScheduleSummary } from "@otto-code/protocol/schedule/types"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; +import { formatThinkingOptionLabel } from "@/composer/agent-controls/utils"; +import { + buildSelectableProviderSelectorProviders, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; +import { + buildProviderDefinitionMapForStatuses, + INITIAL_USER_MODIFIED, + RESOLVABLE_PROVIDER_STATUSES, + resolveDefaultModelId, + resolveFormStateFromProviderModels, + resolveThinkingOptionId, + type FormInitialValues, + type FormState, + type ProviderModelsByProvider, + type UserModifiedFields, +} from "@/provider-selection/resolve-agent-form"; +import { buildProviderDefinitions } from "@/utils/provider-definitions"; +import { shortenPath } from "@/utils/shorten-path"; +import { normalizeScheduleFormCadence } from "./schedule-cadence-options"; +import { PROJECT_OPTION_PREFIX, type ScheduleProjectTarget } from "./schedule-project-targets"; + +export interface ScheduleFormDisplay { + label: string; + description?: string; +} + +export interface ScheduleFormHost { + serverId: string; + label: string; + supportsWorkspaceMultiplicity?: boolean; +} + +export interface ScheduleFormSnapshot { + mode: "create" | "edit"; + schedule?: ScheduleSummary & { serverId?: string; serverName?: string }; + hosts: readonly ScheduleFormHost[]; + defaults: { + serverId?: string | null; + projectTargets: readonly ScheduleProjectTarget[]; + preferences?: FormPreferences; + timezone?: string; + }; +} + +export interface ScheduleFormProviderSnapshot { + entries: ProviderSnapshotEntry[]; +} + +export interface ScheduleDisclosureState { + showProjectField: boolean; + showModelField: boolean; + showThinkingField: boolean; + showIsolationField: boolean; + showArchiveOnFinishField: boolean; +} + +export interface ScheduleProviderSnapshotRequest { + serverId: string; + cwd: string; +} + +export interface ScheduleFormProjectOption { + id: string; + value: string; + label: string; + testID: string; +} + +export type ScheduleFormTargetKind = "agent" | "new-agent"; +type ProviderResolutionStatus = "idle" | "pending" | "complete"; + +export interface ScheduleFormState { + mode: "create" | "edit"; + targetKind: ScheduleFormTargetKind; + name: string; + prompt: string; + maxRuns: string; + cadence: ScheduleCadence; + submitCadence: ScheduleCadence; + hosts: ScheduleFormHost[]; + projectOptions: ScheduleFormProjectOption[]; + selectedServerId: string | null; + selectedProvider: AgentProvider | null; + selectedModel: string; + /** + * Internal only — no mode field is rendered and modeId is never submitted: + * schedule runs are unattended, so a mode picker would be a trap (an + * attended mode fails the run at its first approval prompt). The value + * still rides along the shared provider-selection resolution so the user's + * per-provider mode preference (used by attended forms like the composer) + * is preserved rather than clobbered when this form persists preferences. + */ + selectedMode: string; + selectedThinkingOptionId: string; + workingDir: string; + projectDisplay: ScheduleFormDisplay | null; + selectedProjectOptionId: string; + selectedModelDisplay: ScheduleFormDisplay | null; + selectedThinkingDisplay: ScheduleFormDisplay | null; + modelSelectorProviders: ProviderSelectorProvider[]; + availableThinkingOptions: NonNullable; + archiveOnFinish: boolean; + isolation: "local" | "worktree"; + effectiveIsolation: "local" | "worktree"; + submitArchiveOnFinish: boolean | undefined; + submitIsolation: "local" | "worktree" | undefined; + canUseWorktreeIsolation: boolean; + providerResolutionByServerId: Record; + providerSnapshotRequest: ScheduleProviderSnapshotRequest | null; + disclosure: ScheduleDisclosureState; + canSubmit: boolean; + submitError: string | null; +} + +export interface ScheduleFormModel { + getState: () => ScheduleFormState; + subscribe: (listener: () => void) => () => void; + close: () => void; + applyHosts: (hosts: readonly ScheduleFormHost[]) => void; + applyProjectTargets: (targets: readonly ScheduleProjectTarget[]) => void; + applyPreferences: (preferences: FormPreferences | undefined) => void; + applyProviderSnapshot: (serverId: string, snapshot: ScheduleFormProviderSnapshot) => void; + setHost: (serverId: string | null) => void; + setProject: (optionId: string, display: ScheduleFormDisplay) => void; + setModel: (provider: AgentProvider, modelId: string) => void; + setThinking: (thinkingOptionId: string) => void; + setName: (value: string) => void; + setPrompt: (value: string) => void; + setMaxRuns: (value: string) => void; + setCadence: (value: ScheduleCadence) => void; + setIsolation: (value: "local" | "worktree") => void; + setArchiveOnFinish: (value: boolean) => void; + setSubmitError: (value: string | null) => void; +} + +const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 }; +const DEFAULT_TIMEZONE = "UTC"; + +type ThinkingOption = NonNullable[number]; + +function newAgentConfig(schedule: ScheduleFormSnapshot["schedule"]) { + if (schedule?.target.type === "new-agent") { + return schedule.target.config; + } + return null; +} + +function buildProjectOptionTestId(optionId: string): string { + const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, ""); + return `schedule-project-option-${targetKey}`; +} + +function buildProjectDisplay(target: ScheduleProjectTarget): ScheduleFormDisplay { + return { label: target.projectName }; +} + +function buildStoredProjectDisplay(cwd: string): ScheduleFormDisplay | null { + const storedPath = cwd.trim(); + if (!storedPath) { + return null; + } + return { label: shortenPath(storedPath) }; +} + +function buildProjectOptions( + targets: readonly ScheduleProjectTarget[], + serverId: string | null, +): ScheduleFormProjectOption[] { + if (!serverId) { + return []; + } + return targets + .filter((target) => target.serverId === serverId) + .map((target) => ({ + id: target.optionId, + value: target.optionId, + label: target.projectName, + testID: buildProjectOptionTestId(target.optionId), + })); +} + +function resolveProjectTarget(input: { + targets: readonly ScheduleProjectTarget[]; + serverId: string | null; + cwd: string; +}): ScheduleProjectTarget | null { + const cwd = input.cwd.trim(); + if (!input.serverId || !cwd) { + return null; + } + return ( + input.targets.find((target) => target.serverId === input.serverId && target.cwd === cwd) ?? null + ); +} + +function findProjectTargetByOptionId( + targets: readonly ScheduleProjectTarget[], + optionId: string, +): ScheduleProjectTarget | null { + return targets.find((target) => target.optionId === optionId) ?? null; +} + +function resolveProjectDisplay(input: { + targets: readonly ScheduleProjectTarget[]; + serverId: string | null; + cwd: string; +}): ScheduleFormDisplay | null { + const target = resolveProjectTarget(input); + if (target) { + return buildProjectDisplay(target); + } + return buildStoredProjectDisplay(input.cwd); +} + +function buildProviderModelsByProvider(entries: ProviderSnapshotEntry[]): ProviderModelsByProvider { + const map: ProviderModelsByProvider = new Map(); + for (const entry of entries) { + map.set(entry.provider, entry.models ?? null); + } + return map; +} + +function resolveSelectedEntry( + entries: readonly ProviderSnapshotEntry[], + provider: AgentProvider | null, +): ProviderSnapshotEntry | null { + if (!provider) { + return null; + } + return entries.find((entry) => entry.provider === provider) ?? null; +} + +function resolveAvailableModels( + entries: readonly ProviderSnapshotEntry[], + provider: AgentProvider | null, +): AgentModelDefinition[] | null { + return resolveSelectedEntry(entries, provider)?.models ?? null; +} + +function resolveEffectiveModel( + models: AgentModelDefinition[] | null, + modelId: string, +): AgentModelDefinition | null { + const selectedModelId = modelId.trim(); + if (!models || !selectedModelId) { + return null; + } + return ( + models.find((model) => model.id === selectedModelId) ?? + models.find((model) => model.isDefault) ?? + models[0] ?? + null + ); +} + +function resolveThinkingOptions( + entries: readonly ProviderSnapshotEntry[], + provider: AgentProvider | null, + modelId: string, +): NonNullable { + const model = resolveEffectiveModel(resolveAvailableModels(entries, provider), modelId); + return model?.thinkingOptions ?? []; +} + +function resolveModelDisplay(input: { + entries: readonly ProviderSnapshotEntry[]; + provider: AgentProvider | null; + modelId: string; +}): ScheduleFormDisplay | null { + const modelId = input.modelId.trim(); + if (!modelId) { + return null; + } + const model = resolveEffectiveModel( + resolveAvailableModels(input.entries, input.provider), + modelId, + ); + return { label: model?.label ?? modelId }; +} + +function resolveThinkingDisplay(input: { + options: readonly ThinkingOption[]; + thinkingOptionId: string; +}): ScheduleFormDisplay | null { + const thinkingOptionId = input.thinkingOptionId.trim(); + if (!thinkingOptionId) { + return null; + } + const option = input.options.find((entry) => entry.id === thinkingOptionId) ?? { + id: thinkingOptionId, + }; + return { label: formatThinkingOptionLabel(option) }; +} + +function isSelectedModelValidForProviders(input: { + providers: readonly ProviderSelectorProvider[]; + selectedProvider: AgentProvider | null; + selectedModel: string; +}): boolean { + if (!input.selectedProvider) { + return false; + } + const provider = input.providers.find((entry) => entry.id === input.selectedProvider); + if (!provider || provider.modelSelection.kind !== "models") { + return false; + } + const selectedModel = input.selectedModel.trim(); + if (!selectedModel) { + return true; + } + return provider.modelSelection.rows.some((row) => row.modelId === selectedModel); +} + +function normalizeInitialValues(input: { + snapshot: ScheduleFormSnapshot; + selectedServerId: string | null; +}): FormInitialValues | undefined { + const config = newAgentConfig(input.snapshot.schedule); + if (!config) { + return undefined; + } + return { + serverId: input.selectedServerId, + provider: config.provider, + model: config.model ?? null, + modeId: config.modeId ?? null, + thinkingOptionId: config.thinkingOptionId ?? null, + workingDir: config.cwd, + }; +} + +function resolveInitialServerId(snapshot: ScheduleFormSnapshot): string | null { + if (snapshot.mode === "edit") { + return snapshot.schedule?.serverId ?? snapshot.defaults.serverId ?? null; + } + if (snapshot.defaults.serverId !== undefined) { + return snapshot.defaults.serverId; + } + if (snapshot.hosts.length === 1) { + return snapshot.hosts[0]?.serverId ?? null; + } + return null; +} + +function makeProviderResolutionRecord( + serverId: string | null, +): Record { + if (!serverId) { + return {}; + } + return { [serverId]: "pending" }; +} + +function resolveTargetKind(snapshot: ScheduleFormSnapshot): ScheduleFormTargetKind { + if (snapshot.mode === "edit" && snapshot.schedule?.target.type === "agent") { + return "agent"; + } + return "new-agent"; +} + +function buildProviderSnapshotRequest(input: { + targetKind: ScheduleFormTargetKind; + selectedServerId: string | null; + workingDir: string; +}): ScheduleProviderSnapshotRequest | null { + if (input.targetKind !== "new-agent" || !input.selectedServerId) { + return null; + } + // An empty cwd asks for the host-scoped ("home") snapshot: models load as + // soon as a host is known, before any project is picked. + return { serverId: input.selectedServerId, cwd: input.workingDir.trim() }; +} + +function buildInitialProjectDisplay(input: { + config: ReturnType; + targets: readonly ScheduleProjectTarget[]; + selectedServerId: string | null; +}): ScheduleFormDisplay | null { + if (!input.config) { + return null; + } + return resolveProjectDisplay({ + targets: input.targets, + serverId: input.selectedServerId, + cwd: input.config.cwd, + }); +} + +function buildInitialModelDisplay(modelId: string): ScheduleFormDisplay | null { + if (!modelId) { + return null; + } + return { label: modelId }; +} + +function buildInitialThinkingDisplay(thinkingOptionId: string): ScheduleFormDisplay | null { + if (!thinkingOptionId) { + return null; + } + return { label: formatThinkingOptionLabel({ id: thinkingOptionId }) }; +} + +function formatInitialMaxRuns(schedule: ScheduleFormSnapshot["schedule"]): string { + if (schedule?.maxRuns == null) { + return ""; + } + return String(schedule.maxRuns); +} + +function resolveInitialSubmitCadence( + snapshot: ScheduleFormSnapshot, + initialCadence: ScheduleCadence, +): ScheduleCadence { + if (snapshot.mode === "edit" && snapshot.schedule) { + return snapshot.schedule.cadence; + } + return initialCadence; +} + +function resolveInitialIsolation(input: { + config: ReturnType; + preferences: FormPreferences | undefined; +}): "local" | "worktree" { + if (input.config) { + return input.config.isolation ?? "local"; + } + return input.preferences?.isolation ?? "local"; +} + +function resolveSelectedProjectOptionId(target: ScheduleProjectTarget | null): string { + return target?.optionId ?? ""; +} + +function buildInitialProviderResolution( + request: ScheduleProviderSnapshotRequest | null, +): Record { + if (!request) { + return {}; + } + return makeProviderResolutionRecord(request.serverId); +} + +function resolveCanUseWorktreeIsolation(input: { + state: Pick; + hosts: readonly ScheduleFormHost[]; + targets: readonly ScheduleProjectTarget[]; +}): boolean { + const target = resolveProjectTarget({ + targets: input.targets, + serverId: input.state.selectedServerId, + cwd: input.state.workingDir, + }); + const host = input.hosts.find((entry) => entry.serverId === input.state.selectedServerId); + return Boolean(target?.isGit && host?.supportsWorkspaceMultiplicity); +} + +function selectedHostSupportsWorkspaceMultiplicity(input: { + hosts: readonly ScheduleFormHost[]; + selectedServerId: string | null; +}): boolean { + return ( + input.hosts.find((entry) => entry.serverId === input.selectedServerId) + ?.supportsWorkspaceMultiplicity === true + ); +} + +function resolveEffectiveIsolation(input: { + isolation: "local" | "worktree"; + canUseWorktreeIsolation: boolean; + selectedServerId: string | null; + providerResolutionByServerId: Record; +}): "local" | "worktree" { + if (input.isolation !== "worktree") { + return "local"; + } + if (input.canUseWorktreeIsolation) { + return "worktree"; + } + if ( + !input.selectedServerId || + input.providerResolutionByServerId[input.selectedServerId] !== "complete" + ) { + return "worktree"; + } + return "local"; +} + +function resolveDisclosure(state: ScheduleFormState): ScheduleDisclosureState { + if (state.targetKind === "agent") { + return { + showProjectField: false, + showModelField: false, + showThinkingField: false, + showIsolationField: false, + showArchiveOnFinishField: false, + }; + } + + // Project and model are always offered — models come from the host (a + // project only re-scopes provider config), so neither field waits on the + // other. The remaining flags are capability gates, not disclosure steps: + // effort exists per model, worktrees per git project, archive-on-finish per + // host feature. + const hasSelectedModel = Boolean(state.selectedProvider && state.selectedModel.trim()); + return { + showProjectField: true, + showModelField: true, + showThinkingField: hasSelectedModel && state.availableThinkingOptions.length > 0, + showIsolationField: state.canUseWorktreeIsolation, + showArchiveOnFinishField: selectedHostSupportsWorkspaceMultiplicity({ + hosts: state.hosts, + selectedServerId: state.selectedServerId, + }), + }; +} + +function resolveCanSubmit(state: ScheduleFormState): boolean { + if (state.prompt.trim().length === 0) { + return false; + } + if (state.targetKind === "agent") { + return true; + } + const hasWorkingDir = state.workingDir.trim().length > 0; + const hasMatchedProject = state.selectedProjectOptionId.trim().length > 0; + if (state.mode === "create" && !hasMatchedProject) { + return false; + } + if (!hasWorkingDir) { + return false; + } + return isSelectedModelValidForProviders({ + providers: state.modelSelectorProviders, + selectedProvider: state.selectedProvider, + selectedModel: state.selectedModel, + }); +} + +function updateDerivedState(input: { + state: ScheduleFormState; + hosts: readonly ScheduleFormHost[]; + targets: readonly ScheduleProjectTarget[]; + providerEntries: readonly ProviderSnapshotEntry[]; +}): ScheduleFormState { + const availableThinkingOptions = resolveThinkingOptions( + input.providerEntries, + input.state.selectedProvider, + input.state.selectedModel, + ); + const canUseWorktreeIsolation = resolveCanUseWorktreeIsolation({ + state: input.state, + hosts: input.hosts, + targets: input.targets, + }); + const canSubmitWorkspaceLifecycleOptions = selectedHostSupportsWorkspaceMultiplicity({ + hosts: input.hosts, + selectedServerId: input.state.selectedServerId, + }); + const effectiveIsolation = resolveEffectiveIsolation({ + isolation: input.state.isolation, + canUseWorktreeIsolation, + selectedServerId: input.state.selectedServerId, + providerResolutionByServerId: input.state.providerResolutionByServerId, + }); + const projectTarget = resolveProjectTarget({ + targets: input.targets, + serverId: input.state.selectedServerId, + cwd: input.state.workingDir, + }); + const nextState: ScheduleFormState = { + ...input.state, + hosts: [...input.hosts], + projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId), + selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId, + selectedModelDisplay: resolveModelDisplay({ + entries: input.providerEntries, + provider: input.state.selectedProvider, + modelId: input.state.selectedModel, + }), + selectedThinkingDisplay: resolveThinkingDisplay({ + options: availableThinkingOptions, + thinkingOptionId: input.state.selectedThinkingOptionId, + }), + availableThinkingOptions, + canUseWorktreeIsolation, + effectiveIsolation, + submitArchiveOnFinish: canSubmitWorkspaceLifecycleOptions + ? input.state.archiveOnFinish + : undefined, + submitIsolation: canSubmitWorkspaceLifecycleOptions ? effectiveIsolation : undefined, + }; + const disclosure = resolveDisclosure(nextState); + return { ...nextState, disclosure, canSubmit: resolveCanSubmit({ ...nextState, disclosure }) }; +} + +function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState { + const selectedServerId = resolveInitialServerId(snapshot); + const config = newAgentConfig(snapshot.schedule); + const targetKind = resolveTargetKind(snapshot); + const workingDir = config?.cwd ?? ""; + const selectedProjectTarget = resolveProjectTarget({ + targets: snapshot.defaults.projectTargets, + serverId: selectedServerId, + cwd: workingDir, + }); + const providerSnapshotRequest = buildProviderSnapshotRequest({ + targetKind, + selectedServerId, + workingDir, + }); + const initialCadence = normalizeScheduleFormCadence( + snapshot.schedule?.cadence ?? DEFAULT_CADENCE, + snapshot.defaults.timezone ?? DEFAULT_TIMEZONE, + ); + const initialModel = config?.model ?? ""; + const initialMode = config?.modeId ?? ""; + const initialThinking = config?.thinkingOptionId ?? ""; + const state: ScheduleFormState = { + mode: snapshot.mode, + targetKind, + name: snapshot.schedule?.name ?? "", + prompt: snapshot.schedule?.prompt ?? "", + maxRuns: formatInitialMaxRuns(snapshot.schedule), + cadence: initialCadence, + submitCadence: resolveInitialSubmitCadence(snapshot, initialCadence), + hosts: [...snapshot.hosts], + projectOptions: buildProjectOptions(snapshot.defaults.projectTargets, selectedServerId), + selectedServerId, + selectedProvider: config?.provider ?? null, + selectedModel: initialModel, + selectedMode: initialMode, + selectedThinkingOptionId: initialThinking, + workingDir, + projectDisplay: buildInitialProjectDisplay({ + config, + targets: snapshot.defaults.projectTargets, + selectedServerId, + }), + selectedProjectOptionId: resolveSelectedProjectOptionId(selectedProjectTarget), + selectedModelDisplay: buildInitialModelDisplay(initialModel), + selectedThinkingDisplay: buildInitialThinkingDisplay(initialThinking), + modelSelectorProviders: [], + availableThinkingOptions: [], + archiveOnFinish: config?.archiveOnFinish ?? true, + isolation: resolveInitialIsolation({ config, preferences: snapshot.defaults.preferences }), + effectiveIsolation: "local", + submitArchiveOnFinish: undefined, + submitIsolation: undefined, + canUseWorktreeIsolation: false, + providerResolutionByServerId: buildInitialProviderResolution(providerSnapshotRequest), + providerSnapshotRequest, + disclosure: { + showProjectField: false, + showModelField: false, + showThinkingField: false, + showIsolationField: false, + showArchiveOnFinishField: false, + }, + canSubmit: false, + submitError: null, + }; + return updateDerivedState({ + state, + hosts: snapshot.hosts, + targets: snapshot.defaults.projectTargets, + providerEntries: [], + }); +} + +function toFormState(state: ScheduleFormState): FormState { + return { + serverId: state.selectedServerId, + provider: state.selectedProvider, + modeId: state.selectedMode, + model: state.selectedModel, + thinkingOptionId: state.selectedThinkingOptionId, + workingDir: state.workingDir, + }; +} + +function applyResolvedFormState(state: ScheduleFormState, form: FormState): ScheduleFormState { + return { + ...state, + selectedServerId: form.serverId, + selectedProvider: form.provider, + selectedMode: form.modeId, + selectedModel: form.model, + selectedThinkingOptionId: form.thinkingOptionId, + workingDir: form.workingDir, + }; +} + +function resolveSnapshotSelection(input: { + state: ScheduleFormState; + snapshot: ScheduleFormSnapshot; + initialValues: FormInitialValues | undefined; + preferences: FormPreferences | null; + providerEntries: ProviderSnapshotEntry[]; + userModified: UserModifiedFields; +}): ScheduleFormState { + const providerDefinitions = buildProviderDefinitions(input.providerEntries); + const allowedProviderMap = buildProviderDefinitionMapForStatuses({ + snapshotEntries: input.providerEntries, + providerDefinitions, + statuses: RESOLVABLE_PROVIDER_STATUSES, + }); + const resolved = resolveFormStateFromProviderModels( + input.initialValues, + input.preferences, + buildProviderModelsByProvider(input.providerEntries), + input.userModified, + toFormState(input.state), + allowedProviderMap, + ); + return applyResolvedFormState(input.state, resolved); +} + +function preferencesForSnapshotResolution( + snapshot: ScheduleFormSnapshot, + preferences: FormPreferences | null, +): FormPreferences | null { + return snapshot.mode === "edit" ? null : preferences; +} + +function pickModeForProvider(input: { + entries: readonly ProviderSnapshotEntry[]; + provider: AgentProvider; + currentProvider: AgentProvider | null; + currentMode: string; +}): string { + const currentMode = input.currentMode.trim(); + if (input.currentProvider === input.provider && currentMode) { + return currentMode; + } + const entry = resolveSelectedEntry(input.entries, input.provider); + return entry?.defaultModeId ?? entry?.modes?.[0]?.id ?? ""; +} + +function pickModelForProvider(input: { + entries: readonly ProviderSnapshotEntry[]; + provider: AgentProvider; + modelId: string; +}): string { + const normalizedModelId = input.modelId.trim(); + if (normalizedModelId) { + return normalizedModelId; + } + return resolveDefaultModelId(resolveAvailableModels(input.entries, input.provider)); +} + +export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormModel { + const listeners = new Set<() => void>(); + const initialValues = normalizeInitialValues({ + snapshot, + selectedServerId: resolveInitialServerId(snapshot), + }); + let closed = false; + let hosts = snapshot.hosts; + let projectTargets = snapshot.defaults.projectTargets; + let preferences = snapshot.defaults.preferences ?? null; + let providerEntries: ProviderSnapshotEntry[] = []; + let userModified = { ...INITIAL_USER_MODIFIED, isolation: false }; + const timezone = snapshot.defaults.timezone ?? DEFAULT_TIMEZONE; + let state = buildInitialState(snapshot); + + function publish(nextState: ScheduleFormState): void { + if (closed) { + return; + } + state = updateDerivedState({ + state: nextState, + hosts, + targets: projectTargets, + providerEntries, + }); + for (const listener of listeners) { + listener(); + } + } + + function requestProviderSnapshot(serverId: string | null, cwd: string): void { + if (!serverId) { + publish({ + ...state, + providerSnapshotRequest: null, + }); + return; + } + // Empty cwd = host-scoped ("home") snapshot; see buildProviderSnapshotRequest. + publish({ + ...state, + providerResolutionByServerId: { + ...state.providerResolutionByServerId, + [serverId]: "pending", + }, + providerSnapshotRequest: { serverId, cwd: cwd.trim() }, + }); + } + + function clearProviderSelection(nextState: ScheduleFormState): ScheduleFormState { + providerEntries = []; + return { + ...nextState, + selectedProvider: null, + selectedModel: "", + selectedMode: "", + selectedThinkingOptionId: "", + modelSelectorProviders: [], + availableThinkingOptions: [], + selectedModelDisplay: null, + selectedThinkingDisplay: null, + providerSnapshotRequest: null, + }; + } + + function resolvePreferences(nextState: ScheduleFormState): ScheduleFormState { + let resolved = nextState; + if ( + snapshot.mode === "create" && + !userModified.isolation && + preferences?.isolation !== undefined + ) { + resolved = { ...resolved, isolation: preferences.isolation }; + } + if (providerEntries.length === 0 || resolved.targetKind !== "new-agent") { + return resolved; + } + return resolveSnapshotSelection({ + state: resolved, + snapshot, + initialValues, + preferences: preferencesForSnapshotResolution(snapshot, preferences), + providerEntries, + userModified, + }); + } + + return { + getState: () => state, + subscribe(listener) { + if (closed) { + return () => {}; + } + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + close() { + closed = true; + listeners.clear(); + }, + applyHosts(nextHosts) { + if (closed || hosts === nextHosts) { + return; + } + hosts = nextHosts; + publish(state); + }, + applyProjectTargets(nextTargets) { + if (closed || projectTargets === nextTargets) { + return; + } + projectTargets = nextTargets; + publish(state); + }, + applyPreferences(nextPreferences) { + const normalizedPreferences = nextPreferences ?? null; + if (closed || preferences === normalizedPreferences) { + return; + } + preferences = normalizedPreferences; + publish(resolvePreferences(state)); + }, + applyProviderSnapshot(serverId, providerSnapshot) { + if (closed || state.selectedServerId !== serverId) { + return; + } + providerEntries = providerSnapshot.entries; + const isPendingResolution = state.providerSnapshotRequest?.serverId === serverId; + const resolved = isPendingResolution + ? resolveSnapshotSelection({ + state, + snapshot, + initialValues, + preferences: preferencesForSnapshotResolution(snapshot, preferences), + providerEntries, + userModified, + }) + : state; + const providerResolutionByServerId: Record = { + ...state.providerResolutionByServerId, + }; + if (isPendingResolution) { + providerResolutionByServerId[serverId] = "complete"; + } + publish({ + ...resolved, + modelSelectorProviders: buildSelectableProviderSelectorProviders(providerEntries), + providerResolutionByServerId, + providerSnapshotRequest: isPendingResolution ? null : state.providerSnapshotRequest, + }); + }, + setHost(serverId) { + if (closed || state.selectedServerId === serverId) { + return; + } + userModified = { + ...userModified, + serverId: true, + workingDir: true, + }; + publish( + clearProviderSelection({ + ...state, + selectedServerId: serverId, + workingDir: "", + projectDisplay: null, + selectedProjectOptionId: "", + providerResolutionByServerId: {}, + }), + ); + if (serverId) { + requestProviderSnapshot(serverId, ""); + } + }, + setProject(optionId, display) { + if (closed) { + return; + } + const target = findProjectTargetByOptionId(projectTargets, optionId); + if (!target) { + return; + } + const serverChanged = state.selectedServerId !== target.serverId; + const providerScopeChanged = serverChanged || state.workingDir !== target.cwd; + if (!providerScopeChanged && state.selectedProjectOptionId === target.optionId) { + return; + } + userModified = { ...userModified, serverId: true, workingDir: true }; + const nextState = { + ...state, + selectedServerId: target.serverId, + workingDir: target.cwd, + projectDisplay: display, + selectedProjectOptionId: target.optionId, + }; + // A same-host project change keeps the user's provider/model choice — + // the re-scoped snapshot re-validates it on arrival. Only a host change + // resets the selection. + publish(serverChanged ? clearProviderSelection(nextState) : nextState); + if (providerScopeChanged) { + requestProviderSnapshot(target.serverId, target.cwd); + } + }, + setModel(provider, modelId) { + if (closed) { + return; + } + const selectedModel = pickModelForProvider({ entries: providerEntries, provider, modelId }); + const availableModels = resolveAvailableModels(providerEntries, provider); + const selectedThinkingOptionId = resolveThinkingOptionId({ + availableModels, + modelId: selectedModel, + requestedThinkingOptionId: "", + }); + userModified = { ...userModified, provider: true, model: true }; + publish({ + ...state, + selectedProvider: provider, + selectedModel, + selectedMode: pickModeForProvider({ + entries: providerEntries, + provider, + currentProvider: state.selectedProvider, + currentMode: state.selectedMode, + }), + selectedThinkingOptionId, + }); + }, + setThinking(thinkingOptionId) { + if (closed) { + return; + } + userModified = { ...userModified, thinkingOptionId: true }; + publish({ ...state, selectedThinkingOptionId: thinkingOptionId }); + }, + setName(value) { + publish({ ...state, name: value }); + }, + setPrompt(value) { + publish({ ...state, prompt: value }); + }, + setMaxRuns(value) { + publish({ ...state, maxRuns: value }); + }, + setCadence(value) { + const cadence = normalizeScheduleFormCadence(value, timezone); + publish({ ...state, cadence, submitCadence: cadence }); + }, + setIsolation(value) { + userModified = { ...userModified, isolation: true }; + publish({ ...state, isolation: value }); + }, + setArchiveOnFinish(value) { + publish({ ...state, archiveOnFinish: value }); + }, + setSubmitError(value) { + publish({ ...state, submitError: value }); + }, + }; +} diff --git a/packages/app/src/schedules/schedule-project-targets.test.ts b/packages/app/src/schedules/schedule-project-targets.test.ts new file mode 100644 index 000000000..c4c7d500c --- /dev/null +++ b/packages/app/src/schedules/schedule-project-targets.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { ProjectSummary } from "@/utils/projects"; +import { + buildProjectNameByCwd, + buildScheduleProjectTargets, + describeScheduleCwd, +} from "./schedule-project-targets"; + +function makeProject(overrides: Partial): ProjectSummary { + return { + projectKey: "proj", + projectName: "Project", + hosts: [], + totalWorkspaceCount: 0, + hostCount: 0, + onlineHostCount: 0, + ...overrides, + }; +} + +function makeHost(overrides: Partial) { + return { + serverId: "host-1", + serverName: "Host 1", + isOnline: true, + repoRoot: "/tmp/project", + workspaceCount: 0, + workspaces: [], + ...overrides, + }; +} + +describe("buildScheduleProjectTargets", () => { + it("emits one target per online host with a repo root", () => { + const targets = buildScheduleProjectTargets([ + makeProject({ + projectName: "Alpha", + hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })], + }), + ]); + expect(targets).toHaveLength(2); + expect(targets[0]).toMatchObject({ + serverId: "host-1", + cwd: "/tmp/alpha", + projectName: "Alpha", + }); + }); + + it("skips offline hosts and blank repo roots", () => { + const targets = buildScheduleProjectTargets([ + makeProject({ + hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })], + }), + ]); + expect(targets).toHaveLength(0); + }); +}); + +describe("describeScheduleCwd", () => { + it("prefers a matched project name and shortens unmatched paths", () => { + const byCwd = buildProjectNameByCwd( + buildScheduleProjectTargets([ + makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }), + ]), + ); + expect( + describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }), + ).toBe("Alpha"); + expect( + describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }), + ).toBe("~/api"); + }); +}); diff --git a/packages/app/src/schedules/schedule-project-targets.ts b/packages/app/src/schedules/schedule-project-targets.ts new file mode 100644 index 000000000..3b7c05a1d --- /dev/null +++ b/packages/app/src/schedules/schedule-project-targets.ts @@ -0,0 +1,77 @@ +import type { ProjectSummary } from "@/utils/projects"; +import { shortenPath } from "@/utils/shorten-path"; + +export const PROJECT_OPTION_PREFIX = "project:"; + +export interface ScheduleProjectTarget { + optionId: string; + serverId: string; + serverName: string; + projectKey: string; + projectName: string; + cwd: string; + isGit: boolean; +} + +export function buildProjectOptionId(serverId: string, projectKey: string): string { + return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`; +} + +/** + * The project roots the schedule form can target: one per online host of each + * project, keyed by (serverId, cwd). The schedules list reuses this set to name + * a schedule's stored cwd; the two surfaces must agree on what "a project" is. + */ +export function buildScheduleProjectTargets( + projects: readonly ProjectSummary[], +): ScheduleProjectTarget[] { + const targets: ScheduleProjectTarget[] = []; + for (const project of projects) { + for (const host of project.hosts) { + const cwd = host.repoRoot.trim(); + if (!host.isOnline || !cwd) { + continue; + } + targets.push({ + optionId: buildProjectOptionId(host.serverId, project.projectKey), + serverId: host.serverId, + serverName: host.serverName, + projectKey: project.projectKey, + projectName: project.projectName, + cwd, + isGit: Boolean(host.gitRuntime), + }); + } + } + return targets; +} + +function projectNameKey(serverId: string, cwd: string): string { + return `${serverId}:${cwd.trim()}`; +} + +/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */ +export function buildProjectNameByCwd( + targets: readonly ScheduleProjectTarget[], +): Map { + const byCwd = new Map(); + for (const target of targets) { + byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName); + } + return byCwd; +} + +/** + * Name a stored cwd for display: the matching project name when the client + * knows this root on this host, otherwise the shortened path itself. Never + * blank, never a claim the client cannot back up. + */ +export function describeScheduleCwd(input: { + serverId: string; + cwd: string; + projectNameByCwd: ReadonlyMap; +}): string { + return ( + input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd) + ); +} diff --git a/packages/app/src/schedules/use-schedule-form-model.test.tsx b/packages/app/src/schedules/use-schedule-form-model.test.tsx new file mode 100644 index 000000000..a9cd3ac4e --- /dev/null +++ b/packages/app/src/schedules/use-schedule-form-model.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildProjectOptionId, type ScheduleProjectTarget } from "./schedule-project-targets"; +import { useScheduleFormModel } from "./use-schedule-form-model"; +import type { ScheduleFormSnapshot } from "./schedule-form-model"; + +const HOSTS = [{ serverId: "host-a", label: "Host A" }] as const; +const PROJECT_A_ID = buildProjectOptionId("host-a", "project-a"); + +function projectTarget(input: { + projectKey: string; + projectName: string; + cwd: string; +}): ScheduleProjectTarget { + return { + optionId: buildProjectOptionId("host-a", input.projectKey), + serverId: "host-a", + serverName: "Host A", + projectKey: input.projectKey, + projectName: input.projectName, + cwd: input.cwd, + isGit: true, + }; +} + +function createSnapshot(projectTargets: readonly ScheduleProjectTarget[]): ScheduleFormSnapshot { + return { + mode: "create", + hosts: HOSTS, + defaults: { + serverId: "host-a", + projectTargets, + preferences: {}, + }, + }; +} + +describe("useScheduleFormModel", () => { + afterEach(() => { + cleanup(); + }); + + it("keeps one model instance and draft state while open snapshot inputs churn", () => { + const firstTargets = [ + projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }), + ]; + const { result, rerender } = renderHook(({ snapshot }) => useScheduleFormModel(snapshot), { + initialProps: { snapshot: createSnapshot(firstTargets) }, + }); + const openedModel = result.current; + + act(() => { + openedModel.setName("Draft name"); + openedModel.setPrompt("Run the draft"); + openedModel.setProject(PROJECT_A_ID, { label: "Project A" }); + }); + + rerender({ + snapshot: createSnapshot([ + projectTarget({ projectKey: "project-a", projectName: "Project A", cwd: "/repo/a" }), + ]), + }); + + expect(result.current).toBe(openedModel); + expect(result.current.getState()).toMatchObject({ + name: "Draft name", + prompt: "Run the draft", + selectedServerId: "host-a", + workingDir: "/repo/a", + projectDisplay: { label: "Project A" }, + selectedProjectOptionId: PROJECT_A_ID, + }); + }); +}); diff --git a/packages/app/src/schedules/use-schedule-form-model.ts b/packages/app/src/schedules/use-schedule-form-model.ts new file mode 100644 index 000000000..fe21514cb --- /dev/null +++ b/packages/app/src/schedules/use-schedule-form-model.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { openScheduleForm, type ScheduleFormSnapshot } from "./schedule-form-model"; + +export function useScheduleFormModel(snapshot: ScheduleFormSnapshot) { + const [model] = useState(() => openScheduleForm(snapshot)); + + useEffect(() => { + return () => { + model.close(); + }; + }, [model]); + + useEffect(() => { + model.applyHosts(snapshot.hosts); + model.applyProjectTargets(snapshot.defaults.projectTargets); + model.applyPreferences(snapshot.defaults.preferences); + }, [model, snapshot.hosts, snapshot.defaults.preferences, snapshot.defaults.projectTargets]); + + return model; +} diff --git a/packages/app/src/schedules/use-schedule-form-provider-snapshot.ts b/packages/app/src/schedules/use-schedule-form-provider-snapshot.ts new file mode 100644 index 000000000..d8392443b --- /dev/null +++ b/packages/app/src/schedules/use-schedule-form-provider-snapshot.ts @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import type { ScheduleFormModel, ScheduleFormState } from "./schedule-form-model"; + +export function useScheduleFormProviderSnapshot( + model: ScheduleFormModel, + state: ScheduleFormState, +) { + const serverId = state.providerSnapshotRequest?.serverId ?? state.selectedServerId; + // An empty cwd fetches the host-scoped ("home") snapshot, so models are + // available before a project is picked. + const cwd = state.providerSnapshotRequest?.cwd ?? state.workingDir; + const enabled = state.targetKind === "new-agent" && Boolean(serverId); + const snapshot = useProvidersSnapshot(serverId ?? null, { + cwd, + enabled, + }); + + useEffect(() => { + if (!enabled || !serverId || !snapshot.entries) { + return; + } + model.applyProviderSnapshot(serverId, { entries: snapshot.entries }); + }, [enabled, model, serverId, snapshot.entries]); + + return snapshot; +} diff --git a/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts new file mode 100644 index 000000000..7bd3498b6 --- /dev/null +++ b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts @@ -0,0 +1,37 @@ +import type { BottomAnchorRouteRequest } from "@/agent-stream/bottom-anchor-controller"; + +export interface RouteBottomAnchorIntent { + routeKey: string; + reason: BottomAnchorRouteRequest["reason"]; +} + +export function deriveRouteBottomAnchorIntent(input: { + cachedIntent: RouteBottomAnchorIntent | null; + routeKey: string | null; + hasAppliedAuthoritativeHistoryAtEntry: boolean; +}): RouteBottomAnchorIntent | null { + if (!input.routeKey) { + return null; + } + if (input.cachedIntent?.routeKey === input.routeKey) { + return input.cachedIntent; + } + return { + routeKey: input.routeKey, + reason: input.hasAppliedAuthoritativeHistoryAtEntry ? "resume" : "initial-entry", + }; +} + +export function deriveRouteBottomAnchorRequest(input: { + intent: RouteBottomAnchorIntent | null; + effectiveAgentId: string | null; +}): BottomAnchorRouteRequest | null { + if (!input.intent || !input.effectiveAgentId) { + return null; + } + return { + reason: input.intent.reason, + agentId: input.effectiveAgentId, + requestKey: `${input.intent.routeKey}:${input.intent.reason}`, + }; +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts b/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts new file mode 100644 index 000000000..bcf20830c --- /dev/null +++ b/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + deriveRouteBottomAnchorIntent, + deriveRouteBottomAnchorRequest, +} from "./agent-ready-screen-bottom-anchor"; + +describe("agent-ready-screen bottom anchor intent", () => { + it("latches initial-entry on first route entry before authoritative history is applied", () => { + const intentAtEntry = deriveRouteBottomAnchorIntent({ + cachedIntent: null, + routeKey: "server-1:agent-1", + hasAppliedAuthoritativeHistoryAtEntry: false, + }); + + const intentAfterHistoryApplies = deriveRouteBottomAnchorIntent({ + cachedIntent: intentAtEntry, + routeKey: "server-1:agent-1", + hasAppliedAuthoritativeHistoryAtEntry: true, + }); + + expect(intentAfterHistoryApplies).toEqual({ + routeKey: "server-1:agent-1", + reason: "initial-entry", + }); + expect( + deriveRouteBottomAnchorRequest({ + intent: intentAfterHistoryApplies, + effectiveAgentId: "agent-1", + }), + ).toEqual({ + agentId: "agent-1", + reason: "initial-entry", + requestKey: "server-1:agent-1:initial-entry", + }); + }); + + it("creates resume requests when revisiting an already-hydrated route", () => { + const intent = deriveRouteBottomAnchorIntent({ + cachedIntent: null, + routeKey: "server-1:agent-2", + hasAppliedAuthoritativeHistoryAtEntry: true, + }); + + expect( + deriveRouteBottomAnchorRequest({ + intent, + effectiveAgentId: "agent-2", + }), + ).toEqual({ + agentId: "agent-2", + reason: "resume", + requestKey: "server-1:agent-2:resume", + }); + }); + + it("does not create a request until the effective agent exists", () => { + const intent = deriveRouteBottomAnchorIntent({ + cachedIntent: null, + routeKey: "server-1:agent-3", + hasAppliedAuthoritativeHistoryAtEntry: false, + }); + + expect( + deriveRouteBottomAnchorRequest({ + intent, + effectiveAgentId: null, + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/screens/artifacts-screen.tsx b/packages/app/src/screens/artifacts-screen.tsx new file mode 100644 index 000000000..162106417 --- /dev/null +++ b/packages/app/src/screens/artifacts-screen.tsx @@ -0,0 +1,414 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { ScrollView, Text, View } from "react-native"; +import { useIsFocused } from "@react-navigation/native"; +import { FilePlus, Plus } from "@/components/icons/material-icons"; +import { StyleSheet } from "react-native-unistyles"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ArtifactGrid } from "@/components/artifacts/artifact-grid"; +import { ProjectFilter, type ProjectFilterOption } from "@/components/project-filter"; +import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +import { + ArtifactCreateSheet, + type ArtifactEditTarget, +} from "@/components/artifacts/artifact-create-sheet"; +import { ArtifactViewDialog } from "@/components/artifacts/artifact-view-dialog"; +import { + TranscriptViewDialog, + type TranscriptViewDialogProps, +} from "@/components/transcript-view-dialog"; +import { useArtifacts, type AggregatedArtifact } from "@/artifacts/use-artifacts"; +import { useArtifactMutations } from "@/artifacts/use-artifact-mutations"; +import { artifactBelongsToWorkspace } from "@/artifacts/artifact-derivation"; +import { + buildProjectNameByCwd, + buildScheduleProjectTargets, +} from "@/schedules/schedule-project-targets"; +import { useProjects } from "@/hooks/use-projects"; +import { useHosts } from "@/runtime/host-runtime"; +import type { ArtifactStatus } from "@otto-code/protocol/artifacts/types"; + +type ArtifactStatusFilter = "all" | ArtifactStatus; + +const STATUS_FILTER_OPTIONS: SegmentedControlOption[] = [ + { value: "all", label: "All", testID: "artifacts-filter-all" }, + { value: "ready", label: "Generated", testID: "artifacts-filter-ready" }, + { value: "generating", label: "In progress", testID: "artifacts-filter-generating" }, + { value: "error", label: "Failed", testID: "artifacts-filter-error" }, +]; + +export function ArtifactsScreen(): ReactElement { + const isFocused = useIsFocused(); + if (!isFocused) { + return ; + } + return ; +} + +type ArtifactFormState = + | { mode: "closed" } + | { mode: "create" } + | { mode: "edit"; artifact: AggregatedArtifact }; + +function toEditTarget(artifact: AggregatedArtifact): ArtifactEditTarget { + return { + id: artifact.id, + serverId: artifact.serverId, + projectId: artifact.projectId, + name: artifact.name, + description: artifact.description, + provider: artifact.generationProvider, + model: artifact.generationModel, + thinkingOptionId: artifact.generationThinkingOptionId ?? null, + }; +} + +function ArtifactsScreenContent(): ReactElement { + const { artifacts, isInitialLoad, isError, refetch } = useArtifacts(); + const { toggleStar, deleteArtifact, regenerateArtifact, cancelArtifact } = useArtifactMutations(); + const { projects } = useProjects(); + const hosts = useHosts(); + const showHost = hosts.length > 1; + + const [projectFilter, setProjectFilter] = useState(undefined); + const [statusFilter, setStatusFilter] = useState("all"); + const [form, setForm] = useState({ mode: "closed" }); + const [viewingArtifact, setViewingArtifact] = useState(null); + const [transcriptTarget, setTranscriptTarget] = + useState(null); + const openCreate = useCallback(() => setForm({ mode: "create" }), []); + const handleView = useCallback( + (artifact: AggregatedArtifact) => setViewingArtifact(artifact), + [], + ); + const closeView = useCallback(() => setViewingArtifact(null), []); + const handleViewGenerationChat = useCallback((artifact: AggregatedArtifact) => { + if (!artifact.generationAgentId) { + return; + } + setTranscriptTarget({ + serverId: artifact.serverId, + agentId: artifact.generationAgentId, + title: artifact.name || artifact.id, + }); + }, []); + const closeTranscript = useCallback(() => setTranscriptTarget(null), []); + const handleEdit = useCallback( + (artifact: AggregatedArtifact) => setForm({ mode: "edit", artifact }), + [], + ); + const closeForm = useCallback(() => setForm({ mode: "closed" }), []); + + const scheduleProjectTargets = useMemo(() => buildScheduleProjectTargets(projects), [projects]); + const projectNameByCwd = useMemo( + () => buildProjectNameByCwd(scheduleProjectTargets), + [scheduleProjectTargets], + ); + + // The picker lists every known project (one entry per repo root), not just the + // roots that happen to have artifacts — a project with none should still be + // selectable and show an empty-state watermark. + const projectOptions = useMemo(() => { + const byId = new Map(); + for (const target of scheduleProjectTargets) { + if (!byId.has(target.cwd)) { + byId.set(target.cwd, { id: target.cwd, label: target.projectName }); + } + } + return Array.from(byId.values()); + }, [scheduleProjectTargets]); + + const visibleArtifacts = useMemo( + () => + artifacts.filter( + (artifact) => + (projectFilter === undefined || + artifactBelongsToWorkspace(artifact.projectId, projectFilter)) && + (statusFilter === "all" || artifact.status === statusFilter), + ), + [artifacts, projectFilter, statusFilter], + ); + + const handleStar = useCallback( + (artifact: AggregatedArtifact) => { + void toggleStar({ + serverId: artifact.serverId, + artifactId: artifact.id, + starred: !artifact.starred, + }); + }, + [toggleStar], + ); + + const handleDelete = useCallback( + (artifact: AggregatedArtifact) => { + void deleteArtifact({ serverId: artifact.serverId, artifactId: artifact.id }); + }, + [deleteArtifact], + ); + + const handleRegenerate = useCallback( + (artifact: AggregatedArtifact) => { + void regenerateArtifact({ serverId: artifact.serverId, artifactId: artifact.id }); + }, + [regenerateArtifact], + ); + + const handleCancel = useCallback( + (artifact: AggregatedArtifact) => { + void cancelArtifact({ serverId: artifact.serverId, artifactId: artifact.id }); + }, + [cancelArtifact], + ); + + return ( + + + 0} + isInitialLoad={isInitialLoad} + showLoadError={isError && artifacts.length === 0} + showHost={showHost} + projectNameByCwd={projectNameByCwd} + projectOptions={projectOptions} + projectFilter={projectFilter} + onProjectFilterChange={setProjectFilter} + statusFilter={statusFilter} + onStatusFilterChange={setStatusFilter} + onRetry={refetch} + onCreate={openCreate} + onView={handleView} + onViewGenerationChat={handleViewGenerationChat} + onEdit={handleEdit} + onRegenerate={handleRegenerate} + onCancel={handleCancel} + onStar={handleStar} + onDelete={handleDelete} + /> + + + + + ); +} + +interface ArtifactsBodyProps { + artifacts: AggregatedArtifact[]; + hasAny: boolean; + isInitialLoad: boolean; + showLoadError: boolean; + showHost: boolean; + projectNameByCwd: ReadonlyMap; + projectOptions: ProjectFilterOption[]; + projectFilter: string | undefined; + onProjectFilterChange: (projectId: string | undefined) => void; + statusFilter: ArtifactStatusFilter; + onStatusFilterChange: (status: ArtifactStatusFilter) => void; + onRetry: () => void; + onCreate: () => void; + onView: (artifact: AggregatedArtifact) => void; + onViewGenerationChat: (artifact: AggregatedArtifact) => void; + onEdit: (artifact: AggregatedArtifact) => void; + onRegenerate: (artifact: AggregatedArtifact) => void; + onCancel: (artifact: AggregatedArtifact) => void; + onStar: (artifact: AggregatedArtifact) => void; + onDelete: (artifact: AggregatedArtifact) => void; +} + +function ArtifactsBody({ + artifacts, + hasAny, + isInitialLoad, + showLoadError, + showHost, + projectNameByCwd, + projectOptions, + projectFilter, + onProjectFilterChange, + statusFilter, + onStatusFilterChange, + onRetry, + onCreate, + onView, + onViewGenerationChat, + onEdit, + onRegenerate, + onCancel, + onStar, + onDelete, +}: ArtifactsBodyProps): ReactElement { + if (isInitialLoad) { + return ( + + + + ); + } + + if (showLoadError) { + return ( + + Unable to load artifacts + + + ); + } + + if (!hasAny) { + return ( + + No artifacts yet + + + ); + } + + let emptyFilterText = "No artifacts for this project"; + if (statusFilter !== "all") { + const label = STATUS_FILTER_OPTIONS.find((option) => option.value === statusFilter)?.label; + emptyFilterText = `No ${label?.toLowerCase()} artifacts`; + } + + // The filter is always shown so every project stays selectable — including + // ones with no artifacts, which fall through to the empty text below. + return ( + + + + + + + + + + + + {artifacts.length > 0 ? ( + + ) : ( + + {emptyFilterText} + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + body: { + flex: 1, + minHeight: 0, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: theme.spacing[6], + padding: theme.spacing[6], + }, + filterRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] }, + paddingTop: theme.spacing[4], + }, + projectFilterSlot: { + flexShrink: 1, + }, + // Tames the compactUp button doubling so the button, the project filter + // beside it, and the status filter below all share the compact 32px control + // height at every width. + newButton: { + minHeight: 32, + paddingHorizontal: theme.spacing[4], + }, + statusRow: { + flexDirection: "row", + justifyContent: { xs: "center", md: "flex-start" }, + paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] }, + paddingTop: theme.spacing[3], + }, + scroll: { + flex: 1, + minHeight: 0, + }, + scrollContent: { + paddingTop: theme.spacing[4], + paddingBottom: theme.spacing[6], + }, + filterEmpty: { + paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] }, + paddingVertical: theme.spacing[6], + alignItems: "center", + }, + filterEmptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + message: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.lg, + textAlign: "center", + }, + spinner: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/new-project-screen.tsx b/packages/app/src/screens/new-project-screen.tsx new file mode 100644 index 000000000..0735eccab --- /dev/null +++ b/packages/app/src/screens/new-project-screen.tsx @@ -0,0 +1,413 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "expo-router"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { ProjectScaffoldStep } from "@otto-code/protocol/messages"; +import { ScreenHeader } from "@/components/headers/screen-header"; +import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; +import { normalizeEmptyProjectDescriptor, useSessionStore } from "@/stores/session-store"; +import { useOpenProject } from "@/hooks/use-open-project"; +import type { Theme } from "@/styles/theme"; +import { DirectoryField } from "@/screens/new-project/directory-field"; +import { NewProjectDetailFields } from "@/screens/new-project/new-project-detail-fields"; +import { NewProjectPickerRow } from "@/screens/new-project/new-project-picker-row"; +import { + buildScaffoldGitRequest, + createNewProjectFormState, + findDuplicateProjectPath, + getNewProjectBlocker, + previewProjectPath, + type NewProjectCapabilities, + type NewProjectFormState, + type NewProjectMode, +} from "@/screens/new-project/new-project-form"; +import { getHostProjectSourceDirectory, useHostProjects } from "@/projects/host-projects"; +import { + PROVIDERS_REQUIRING_OWNER, + useNewProjectHosting, +} from "@/screens/new-project/use-new-project-hosting"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import type { Href } from "expo-router"; + +// Wrapped once at module scope so the themed color prop tracks the theme +// without subscribing the screen to every runtime change. +const ThemedSpinner = withUnistyles(LoadingSpinner); +const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { + if (input.isCompact) { + return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; + } + return [styles.content, styles.contentCentered]; +} + +interface NewProjectScreenProps { + serverId?: string; +} + +export function NewProjectScreen({ serverId: serverIdProp }: NewProjectScreenProps) { + const { t } = useTranslation(); + const router = useRouter(); + const hosts = useHosts(); + const isCompact = useIsCompactFormFactor(); + const insets = useSafeAreaInsets(); + + const [selectedServerId, setSelectedServerId] = useState( + () => serverIdProp?.trim() || hosts[0]?.serverId || "", + ); + const [form, setForm] = useState(createNewProjectFormState); + // Only the step currently running, for the status line. The full step list is + // build detail — it belongs in a failure message, not on screen during a + // successful run. + const [runningStep, setRunningStep] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const client = useHostRuntimeClient(selectedServerId); + const openProject = useOpenProject(selectedServerId); + const addEmptyProject = useSessionStore((state) => state.addEmptyProject); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); + + const hosting = useNewProjectHosting({ + serverId: selectedServerId, + provider: form.remoteProvider, + wantsOwners: form.mode === "create" && form.gitSetup === "remote", + wantsRepositories: form.mode === "clone", + }); + + const update = useCallback( + (key: K, value: NewProjectFormState[K]) => { + setForm((current) => ({ ...current, [key]: value })); + setErrorMessage(null); + }, + [], + ); + + const capabilities = useMemo( + () => ({ + canScaffold: hosting.canScaffold, + remoteCapableProviders: hosting.connectedProviders, + providersRequiringOwner: PROVIDERS_REQUIRING_OWNER, + }), + [hosting.canScaffold, hosting.connectedProviders], + ); + + const blocker = getNewProjectBlocker(form, capabilities); + const pathPreview = previewProjectPath(form); + + // Adding a directory that is already a project succeeds silently on the daemon + // (find-or-create returns the existing record), so the page would close having + // done nothing. Refuse it here, the way New workspace refuses an occupied + // directory, and say which project already owns the path. + const hostProjects = useHostProjects(useMemo(() => [selectedServerId], [selectedServerId])); + const existingProjectPaths = useMemo( + () => + hostProjects.flatMap((project) => { + const directory = getHostProjectSourceDirectory(project, selectedServerId); + return directory ? [directory] : []; + }), + [hostProjects, selectedServerId], + ); + const duplicateProjectPath = findDuplicateProjectPath({ + targetPath: pathPreview, + existingProjectPaths, + }); + + const handleModeChange = useCallback((mode: NewProjectMode) => { + setRunningStep(null); + setErrorMessage(null); + setForm((current) => ({ ...current, mode })); + }, []); + + const handleDirectoryChange = useCallback( + (directory: string) => update("directory", directory), + [update], + ); + + const handleProgress = useCallback( + (progress: { step: string; status: ProjectScaffoldStep["status"] }) => { + if (progress.status === "running") { + setRunningStep(progress.step); + } + }, + [], + ); + + const submitOpen = useCallback(async () => { + const result = await openProject(form.directory.trim()); + if (!result.ok) { + setErrorMessage( + result.error ?? t(`projectPicker.errors.${result.errorCode ?? "open_failed"}`), + ); + return; + } + // Back is right when the page was pushed from an entry point. On a direct + // load (restored URL, deep link) there is nothing to go back to, so land on + // the home screen rather than a dead end. + if (router.canGoBack()) { + router.back(); + return; + } + router.replace("/" as Href); + }, [form.directory, openProject, router, t]); + + const submitScaffold = useCallback(async () => { + const git = buildScaffoldGitRequest(form, capabilities); + if (!git || !client) { + return; + } + const payload = await client.scaffoldProject({ + parentDirectory: form.directory.trim(), + folderName: form.folderName.trim() || undefined, + git, + onProgress: handleProgress, + }); + + if (payload.error || !payload.project) { + setErrorMessage(payload.error ?? t("newProject.errors.failed")); + return; + } + + // The same store update project.add performs, so the new project shows up + // in the sidebar without waiting for a workspace refetch. + addEmptyProject(selectedServerId, normalizeEmptyProjectDescriptor(payload.project)); + setHasHydratedWorkspaces(selectedServerId, true); + // A freshly scaffolded project has no workspace yet, so routing to one lands + // on "Workspace not found". Hand off to New workspace with the project + // preselected instead — creating a workspace is what you came here to do next. + router.replace( + buildNewWorkspaceRoute({ + serverId: selectedServerId, + projectId: payload.project.projectId, + sourceDirectory: payload.path ?? undefined, + displayName: payload.project.projectDisplayName, + }) as Href, + ); + }, [ + addEmptyProject, + capabilities, + client, + form, + handleProgress, + router, + selectedServerId, + setHasHydratedWorkspaces, + t, + ]); + + const handleSubmitPress = useCallback(() => { + if (blocker || duplicateProjectPath || isSubmitting || !client) { + return; + } + setIsSubmitting(true); + setErrorMessage(null); + setRunningStep(null); + void (form.mode === "open" ? submitOpen() : submitScaffold()) + .catch((error: unknown) => { + setErrorMessage(error instanceof Error ? error.message : t("newProject.errors.failed")); + }) + .finally(() => setIsSubmitting(false)); + }, [ + blocker, + client, + duplicateProjectPath, + form.mode, + isSubmitting, + submitOpen, + submitScaffold, + t, + ]); + + const screenHeaderLeft = useMemo(() => , []); + const contentStyle = getContentStyle({ isCompact, insetBottom: insets.bottom }); + + const directoryPlaceholder = + form.mode === "open" + ? t("newProject.fields.folderPlaceholder") + : t("newProject.fields.parentFolderPlaceholder"); + const submitLabel = + form.mode === "open" ? t("newProject.actions.open") : t("newProject.actions.create"); + const blockerMessage = blocker ? t(`newProject.blockers.${blocker}`) : null; + // A newer daemon may report a step this build has no label for, so fall back + // to the generic "working" line rather than printing a raw id. + const statusMessage = runningStep + ? t(`newProject.status.${runningStep}`, { defaultValue: t("newProject.status.working") }) + : t("newProject.status.working"); + + return ( + + + + + + + {t("newProject.title")} + + + + + + + + + + {pathPreview ? ( + + {pathPreview} + + ) : null} + + {/* One status line, not a step log: the sequence is build detail + the user did not ask to watch. Failures still get the full + story, from the daemon's error message. */} + {isSubmitting ? ( + + + + {statusMessage} + + + ) : null} + + {/* A conflict, not a not-filled-in-yet field, so it reads as an + error rather than the muted blocker hint below. */} + {duplicateProjectPath ? ( + + {t("newProject.errors.alreadyAdded", { path: duplicateProjectPath })} + + ) : null} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {/* Naming the missing field beats a disabled button with no + explanation. */} + {blockerMessage && !duplicateProjectPath ? ( + + {blockerMessage} + + ) : null} + + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + userSelect: "none", + }, + content: { + position: "relative", + flex: 1, + alignItems: "center", + }, + contentCentered: { + justifyContent: "center", + // The header sits above this centring region, so reserve the same height at + // the bottom — otherwise the block centres in the below-header space and + // reads as sitting too low. + paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6], + }, + contentCompact: { + justifyContent: "flex-end", + }, + centered: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + }, + titleContainer: { + marginBottom: theme.spacing[8], + paddingLeft: theme.spacing[6], + paddingRight: theme.spacing[4], + }, + title: { + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + }, + body: { + marginTop: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[3], + }, + // The action is a normal-sized button pinned to the trailing edge, not a + // full-width bar — the column is a form, not a dialog footer. + actionRow: { + flexDirection: "row", + justifyContent: "flex-end", + marginTop: theme.spacing[1], + }, + pathPreview: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontFamily: theme.fontFamily.mono, + }, + statusRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + statusText: { + flex: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + errorText: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, + blockerText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, +})); diff --git a/packages/app/src/screens/new-project/directory-field.tsx b/packages/app/src/screens/new-project/directory-field.tsx new file mode 100644 index 000000000..7fae55377 --- /dev/null +++ b/packages/app/src/screens/new-project/directory-field.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ScrollView, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useFetchQuery } from "@/data/query"; +import { ProjectPickerBrowseButton } from "@/components/project-picker-browse-button"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; +import { buildProjectPickerOptions } from "@/components/project-picker-options"; +import { shortenPath } from "@/utils/shorten-path"; +import { NewProjectSuggestionRow, NewProjectTextInput } from "./new-project-inputs"; + +// The page's primary input: the daemon-backed folder search the retired picker +// modal used, rendered inline under the field instead of in a floating list. + +// Long enough that fast typing doesn't fire a filesystem scan per keystroke. +const SUGGESTION_DEBOUNCE_MS = 250; +const SUGGESTION_LIMIT = 20; + +interface DirectoryFieldProps { + serverId: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + disabled?: boolean; + testID?: string; +} + +export function DirectoryField({ + serverId, + value, + onChange, + placeholder, + disabled, + testID, +}: DirectoryFieldProps) { + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const recommendedPaths = useRecommendedProjectPaths(serverId); + + const [debouncedQuery, setDebouncedQuery] = useState(value); + // Suggestions stay hidden until the field is touched, so a seeded value + // doesn't open a list the user never asked for. + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + const id = setTimeout(() => setDebouncedQuery(value), SUGGESTION_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [value]); + + const suggestionsQuery = useFetchQuery({ + dataShape: "list", + staleTimeMs: 15_000, + queryKey: ["new-project-directory-suggestions", serverId, debouncedQuery], + queryFn: async () => { + if (!client) { + return [] as string[]; + } + const result = await client.getDirectorySuggestions({ + query: debouncedQuery, + includeDirectories: true, + includeFiles: false, + limit: SUGGESTION_LIMIT, + }); + return ( + result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? [] + ); + }, + enabled: Boolean(client) && isConnected && isEditing && !disabled, + retry: false, + }); + + const options = useMemo( + () => + buildProjectPickerOptions({ + recommendedPaths, + // Results answer `debouncedQuery`; filtering against the live `value` + // keeps a slow response from repainting under newer typing. + serverPaths: debouncedQuery === value ? (suggestionsQuery.data ?? []) : [], + query: value, + }), + [debouncedQuery, recommendedPaths, suggestionsQuery.data, value], + ); + + const handleChangeText = useCallback( + (text: string) => { + setIsEditing(true); + onChange(text); + }, + [onChange], + ); + + const handleSelect = useCallback( + (path: string) => { + onChange(path); + setIsEditing(false); + }, + [onChange], + ); + + const handleBrowseError = useCallback(() => setIsEditing(false), []); + + const browseButton = useMemo( + () => ( + + ), + [disabled, handleBrowseError, handleSelect, serverId], + ); + + const showSuggestions = isEditing && !disabled && options.length > 0; + + return ( + + + {showSuggestions ? ( + + {options.map((option) => ( + + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[2], + }, + suggestions: { + maxHeight: 200, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface1, + }, + suggestionsContent: { + paddingVertical: theme.spacing[1], + }, +})); diff --git a/packages/app/src/screens/new-project/new-project-badge-picker.tsx b/packages/app/src/screens/new-project/new-project-badge-picker.tsx new file mode 100644 index 000000000..5e9407ff2 --- /dev/null +++ b/packages/app/src/screens/new-project/new-project-badge-picker.tsx @@ -0,0 +1,160 @@ +import { useCallback, useRef, useState, type ReactNode } from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ChevronDown } from "@/components/icons/material-icons"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { compactUp, type Theme } from "@/styles/theme"; + +// The New project page's pickers are pills in a row above the input, the same +// language New workspace uses for project / host / isolation / base — not +// stacked settings rows. Everything selectable on this page goes through here so +// the row stays visually uniform however many choices a mode adds. + +const BADGE_HEIGHT = 28; + +const ThemedChevronDown = withUnistyles(ChevronDown); +const chevronMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, + size: theme.iconSize.sm, +}); + +interface NewProjectBadgePickerProps { + // An already-themed icon element. Callers wrap their icon once at module + // scope with withUnistyles; wrapping per render would remount it every time. + icon: ReactNode; + label: string; + tooltip: string; + options: ComboboxOption[]; + value: string; + onSelect: (id: string) => void; + disabled?: boolean; + searchable?: boolean; + searchPlaceholder?: string; + emptyText?: string; + title: string; + testID?: string; +} + +export function NewProjectBadgePicker({ + icon, + label, + tooltip, + options, + value, + onSelect, + disabled = false, + searchable = false, + searchPlaceholder, + emptyText, + title, + testID, +}: NewProjectBadgePickerProps) { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const handlePress = useCallback(() => setOpen(true), []); + const handleSelect = useCallback( + (id: string) => { + onSelect(id); + setOpen(false); + }, + [onSelect], + ); + + const badgeStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.badge, + Boolean(hovered) && !disabled && styles.badgeHovered, + pressed && !disabled && styles.badgePressed, + disabled && styles.badgeDisabled, + ], + [disabled], + ); + + return ( + + + + + {icon} + + {label} + + + + + + {tooltip} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + badge: { + flexDirection: "row", + alignItems: "center", + // 1.5x on compact to wrap the badge icons' compact upscale — otherwise the + // theme-scaled icon/text get clipped by the fixed desktop height. + height: compactUp(BADGE_HEIGHT, 1.5), + maxWidth: 240, + overflow: "hidden", + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + gap: theme.spacing[1], + }, + badgeHovered: { + backgroundColor: theme.colors.surface2, + }, + badgePressed: { + backgroundColor: theme.colors.surface0, + }, + badgeDisabled: { + opacity: 0.6, + }, + badgeText: { + minWidth: 0, + fontSize: { + xs: theme.fontSize.sm + 2, + md: theme.fontSize.sm, + }, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + badgeIconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.popoverForeground, + }, +})); diff --git a/packages/app/src/screens/new-project/new-project-detail-fields.tsx b/packages/app/src/screens/new-project/new-project-detail-fields.tsx new file mode 100644 index 000000000..c8d284e18 --- /dev/null +++ b/packages/app/src/screens/new-project/new-project-detail-fields.tsx @@ -0,0 +1,117 @@ +import { useCallback } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { NewProjectField, NewProjectSwitchRow, NewProjectTextInput } from "./new-project-inputs"; +import type { NewProjectFormState } from "./new-project-form"; + +// The text fields a mode needs beyond the folder path. Everything selectable +// lives in the badge row above the input; only free text lands here, so the +// column stays short whichever mode is active. + +export type NewProjectFieldUpdate = ( + key: K, + value: NewProjectFormState[K], +) => void; + +interface NewProjectDetailFieldsProps { + form: NewProjectFormState; + disabled: boolean; + onUpdate: NewProjectFieldUpdate; +} + +export function NewProjectDetailFields({ form, disabled, onUpdate }: NewProjectDetailFieldsProps) { + const { t } = useTranslation(); + + const setFolderName = useCallback((value: string) => onUpdate("folderName", value), [onUpdate]); + const setCloneUrl = useCallback((value: string) => onUpdate("cloneUrl", value), [onUpdate]); + const setInitialBranch = useCallback( + (value: string) => onUpdate("initialBranch", value), + [onUpdate], + ); + const setRemoteName = useCallback((value: string) => onUpdate("remoteName", value), [onUpdate]); + const setAddReadme = useCallback((value: boolean) => onUpdate("addReadme", value), [onUpdate]); + + if (form.mode === "open") { + return null; + } + + const isClone = form.mode === "clone"; + const showRepoSetup = !isClone && form.gitSetup !== "none"; + + return ( + + {isClone ? ( + + + + ) : null} + + + + + + {showRepoSetup ? ( + <> + + + + + {form.gitSetup === "remote" ? ( + + + + ) : null} + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + fields: { + gap: theme.spacing[3], + }, +})); diff --git a/packages/app/src/screens/new-project/new-project-form.test.ts b/packages/app/src/screens/new-project/new-project-form.test.ts new file mode 100644 index 000000000..1516d8211 --- /dev/null +++ b/packages/app/src/screens/new-project/new-project-form.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; +import { + buildScaffoldGitRequest, + createNewProjectFormState, + deriveFolderNameFromCloneUrl, + detectPathSeparator, + findDuplicateProjectPath, + getNewProjectBlocker, + previewProjectPath, + resolveRemoteRepositoryName, + type NewProjectCapabilities, + type NewProjectFormState, +} from "./new-project-form"; + +const CAPABLE: NewProjectCapabilities = { + canScaffold: true, + remoteCapableProviders: ["github", "bitbucket-cloud"], + providersRequiringOwner: ["bitbucket-cloud"], +}; + +function form(overrides: Partial): NewProjectFormState { + return { ...createNewProjectFormState(), ...overrides }; +} + +describe("getNewProjectBlocker", () => { + it("needs a directory in every mode", () => { + expect(getNewProjectBlocker(form({ mode: "open" }), CAPABLE)).toBe("directory_required"); + expect(getNewProjectBlocker(form({ mode: "create" }), CAPABLE)).toBe("directory_required"); + expect(getNewProjectBlocker(form({ mode: "clone" }), CAPABLE)).toBe("directory_required"); + }); + + it("accepts an existing folder with nothing else filled in", () => { + expect(getNewProjectBlocker(form({ mode: "open", directory: "/src" }), CAPABLE)).toBeNull(); + }); + + it("blocks every scaffolding mode when the host cannot scaffold", () => { + const incapable: NewProjectCapabilities = { ...CAPABLE, canScaffold: false }; + expect(getNewProjectBlocker(form({ mode: "create", directory: "/src" }), incapable)).toBe( + "scaffold_unsupported", + ); + // ...but never the open path, which works on every daemon. + expect(getNewProjectBlocker(form({ mode: "open", directory: "/src" }), incapable)).toBeNull(); + }); + + it("needs a folder name to create one", () => { + expect(getNewProjectBlocker(form({ mode: "create", directory: "/src" }), CAPABLE)).toBe( + "folder_name_required", + ); + expect( + getNewProjectBlocker( + form({ mode: "create", directory: "/src", folderName: "thing", gitSetup: "init" }), + CAPABLE, + ), + ).toBeNull(); + }); + + it("needs a URL to clone, but no folder name", () => { + expect(getNewProjectBlocker(form({ mode: "clone", directory: "/src" }), CAPABLE)).toBe( + "clone_url_required", + ); + expect( + getNewProjectBlocker( + form({ mode: "clone", directory: "/src", cloneUrl: "https://host/a/b.git" }), + CAPABLE, + ), + ).toBeNull(); + }); + + describe("remote creation", () => { + const base = form({ + mode: "create", + directory: "/src", + folderName: "thing", + gitSetup: "remote", + }); + + it("requires a provider that can actually create repositories", () => { + expect(getNewProjectBlocker(base, CAPABLE)).toBe("remote_provider_required"); + expect(getNewProjectBlocker({ ...base, remoteProvider: "gitlab" }, CAPABLE)).toBe( + "remote_provider_required", + ); + }); + + it("falls back to the folder name for the repository name", () => { + expect(getNewProjectBlocker({ ...base, remoteProvider: "github" }, CAPABLE)).toBeNull(); + }); + + it("requires an owner only for providers that cannot infer one", () => { + expect(getNewProjectBlocker({ ...base, remoteProvider: "bitbucket-cloud" }, CAPABLE)).toBe( + "remote_owner_required", + ); + expect( + getNewProjectBlocker( + { ...base, remoteProvider: "bitbucket-cloud", remoteOwner: "team" }, + CAPABLE, + ), + ).toBeNull(); + }); + + it("reports a missing name when neither the folder nor the field has one", () => { + expect( + getNewProjectBlocker({ ...base, folderName: " ", remoteProvider: "github" }, CAPABLE), + ).toBe("folder_name_required"); + }); + }); +}); + +describe("resolveRemoteRepositoryName", () => { + it("defaults to the folder name and yields to an explicit override", () => { + expect(resolveRemoteRepositoryName(form({ folderName: "thing" }))).toBe("thing"); + expect(resolveRemoteRepositoryName(form({ folderName: "thing", remoteName: "other" }))).toBe( + "other", + ); + }); +}); + +describe("buildScaffoldGitRequest", () => { + it("returns null for the open path and for an unsubmittable form", () => { + expect(buildScaffoldGitRequest(form({ mode: "open", directory: "/src" }), CAPABLE)).toBeNull(); + expect(buildScaffoldGitRequest(form({ mode: "create", directory: "" }), CAPABLE)).toBeNull(); + }); + + it("builds a plain-folder request", () => { + expect( + buildScaffoldGitRequest( + form({ mode: "create", directory: "/src", folderName: "thing", gitSetup: "none" }), + CAPABLE, + ), + ).toEqual({ kind: "none" }); + }); + + it("builds an init request with the starter-file choices", () => { + expect( + buildScaffoldGitRequest( + form({ + mode: "create", + directory: "/src", + folderName: "thing", + gitSetup: "init", + initialBranch: "trunk", + addReadme: true, + gitignoreTemplate: "node", + }), + CAPABLE, + ), + ).toEqual({ + kind: "init", + initialBranch: "trunk", + addReadme: true, + gitignoreTemplate: "node", + initialCommit: true, + }); + }); + + it("omits an empty branch so the daemon uses git's own default", () => { + const request = buildScaffoldGitRequest( + form({ + mode: "create", + directory: "/src", + folderName: "thing", + gitSetup: "init", + initialBranch: " ", + }), + CAPABLE, + ); + expect(request).toMatchObject({ kind: "init" }); + expect((request as { initialBranch?: string }).initialBranch).toBeUndefined(); + }); + + it("builds a remote request, defaulting the name and nulling an empty owner", () => { + expect( + buildScaffoldGitRequest( + form({ + mode: "create", + directory: "/src", + folderName: "thing", + gitSetup: "remote", + remoteProvider: "github", + remoteVisibility: "public", + remoteDescription: " a thing ", + }), + CAPABLE, + ), + ).toMatchObject({ + kind: "init", + remote: { + providerId: "github", + owner: null, + name: "thing", + description: "a thing", + visibility: "public", + }, + }); + }); + + it("builds a clone request", () => { + expect( + buildScaffoldGitRequest( + form({ mode: "clone", directory: "/src", cloneUrl: " https://host/a/b.git " }), + CAPABLE, + ), + ).toEqual({ kind: "clone", url: "https://host/a/b.git" }); + }); +}); + +describe("previewProjectPath", () => { + it("shows the directory itself when opening an existing folder", () => { + expect(previewProjectPath(form({ mode: "open", directory: "/src/thing" }))).toBe("/src/thing"); + }); + + it("joins the parent and the new folder without doubling the separator", () => { + expect( + previewProjectPath(form({ mode: "create", directory: "/src/", folderName: "thing" })), + ).toBe("/src/thing"); + expect( + previewProjectPath(form({ mode: "create", directory: "C:\\src\\", folderName: "thing" })), + ).toBe("C:\\src\\thing"); + }); + + it("joins with the separator the directory already uses, never a mixed path", () => { + // The reported bug: a Windows parent joined with "/". + expect( + previewProjectPath( + form({ mode: "create", directory: "C:\\Users\\phili\\Projects", folderName: "test-new" }), + ), + ).toBe("C:\\Users\\phili\\Projects\\test-new"); + // A Windows drive written with forward slashes keeps forward slashes. + expect( + previewProjectPath(form({ mode: "create", directory: "C:/src", folderName: "thing" })), + ).toBe("C:/src/thing"); + expect( + previewProjectPath(form({ mode: "create", directory: "/home/me", folderName: "thing" })), + ).toBe("/home/me/thing"); + }); + + it("previews the clone's derived folder before one is typed", () => { + expect( + previewProjectPath( + form({ mode: "clone", directory: "/src", cloneUrl: "https://host/a/b.git" }), + ), + ).toBe("/src/b"); + }); + + it("returns null while there is nothing to preview", () => { + expect(previewProjectPath(form({ mode: "create", directory: "" }))).toBeNull(); + expect(previewProjectPath(form({ mode: "create", directory: "/src" }))).toBeNull(); + }); +}); + +describe("detectPathSeparator", () => { + it("copies the separator the directory already uses", () => { + expect(detectPathSeparator("C:\\Users\\me")).toBe("\\"); + expect(detectPathSeparator("/home/me")).toBe("/"); + expect(detectPathSeparator("C:/src")).toBe("/"); + }); + + it("treats a bare drive letter as Windows, since it has none to copy", () => { + expect(detectPathSeparator("C:")).toBe("\\"); + }); +}); + +describe("findDuplicateProjectPath", () => { + const existing = ["/src/thing", "/src/other"]; + + it("catches a folder that is already a project", () => { + expect( + findDuplicateProjectPath({ targetPath: "/src/thing", existingProjectPaths: existing }), + ).toBe("/src/thing"); + }); + + it("ignores separator and trailing-slash differences", () => { + expect( + findDuplicateProjectPath({ targetPath: "/src/thing/", existingProjectPaths: existing }), + ).toBe("/src/thing"); + expect( + findDuplicateProjectPath({ + targetPath: "C:\\src\\thing", + existingProjectPaths: ["C:/src/thing"], + }), + ).toBe("C:/src/thing"); + }); + + it("returns null for a path no project owns", () => { + expect( + findDuplicateProjectPath({ targetPath: "/src/fresh", existingProjectPaths: existing }), + ).toBeNull(); + }); + + it("returns null while there is no target yet", () => { + expect( + findDuplicateProjectPath({ targetPath: null, existingProjectPaths: existing }), + ).toBeNull(); + expect( + findDuplicateProjectPath({ targetPath: " ", existingProjectPaths: existing }), + ).toBeNull(); + }); + + it("catches a create/clone target that lands on an existing project", () => { + // previewProjectPath is what feeds this, so the create path is covered too. + const target = previewProjectPath( + form({ mode: "create", directory: "/src", folderName: "thing" }), + ); + expect(findDuplicateProjectPath({ targetPath: target, existingProjectPaths: existing })).toBe( + "/src/thing", + ); + }); +}); + +describe("deriveFolderNameFromCloneUrl", () => { + it("matches the daemon's derivation", () => { + expect(deriveFolderNameFromCloneUrl("https://github.com/otto/otto-code.git")).toBe("otto-code"); + expect(deriveFolderNameFromCloneUrl("git@github.com:otto/otto-code.git")).toBe("otto-code"); + expect(deriveFolderNameFromCloneUrl("")).toBe(""); + }); +}); diff --git a/packages/app/src/screens/new-project/new-project-form.ts b/packages/app/src/screens/new-project/new-project-form.ts new file mode 100644 index 000000000..bce525586 --- /dev/null +++ b/packages/app/src/screens/new-project/new-project-form.ts @@ -0,0 +1,224 @@ +import type { ProjectScaffoldGit } from "@otto-code/protocol/messages"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; + +// The New project page's form model, kept out of the component so every rule — +// which fields a mode needs, when Create is allowed, what the resulting git +// request looks like — is testable without rendering. + +// What the user is doing. "open" is the pre-existing behaviour (adopt a folder +// that is already on the host); the rest are the scaffolding paths. +export type NewProjectMode = "open" | "create" | "clone"; + +// Within "create", how much git setup to do. Kept separate from the mode so the +// page can present it as a second, smaller choice rather than a 5-way switch. +export type NewProjectGitSetup = "none" | "init" | "remote"; + +export interface NewProjectFormState { + mode: NewProjectMode; + // "open": the directory to adopt. Otherwise: the parent to create inside. + directory: string; + // Folder to create. Unused by "open"; optional for "clone" (derived from URL). + folderName: string; + gitSetup: NewProjectGitSetup; + initialBranch: string; + addReadme: boolean; + gitignoreTemplate: string | null; + // Remote creation (gitSetup === "remote"). + remoteProvider: string | null; + remoteOwner: string | null; + remoteName: string; + remoteVisibility: "private" | "public"; + remoteDescription: string; + // Clone source. + cloneUrl: string; +} + +export function createNewProjectFormState(): NewProjectFormState { + return { + mode: "open", + directory: "", + folderName: "", + gitSetup: "init", + initialBranch: "main", + addReadme: true, + gitignoreTemplate: null, + remoteProvider: null, + remoteOwner: null, + remoteName: "", + remoteVisibility: "private", + remoteDescription: "", + cloneUrl: "", + }; +} + +// Why Create is disabled. The page shows this next to the offending field +// rather than a generic "fill in the form". +export type NewProjectBlocker = + | "directory_required" + | "folder_name_required" + | "clone_url_required" + | "remote_provider_required" + | "remote_name_required" + | "remote_owner_required" + | "scaffold_unsupported"; + +export interface NewProjectCapabilities { + // server_info.features.projectScaffold + canScaffold: boolean; + // Providers that reported the createRepository capability and are connected. + remoteCapableProviders: readonly string[]; + // Providers that require an explicit owner — Bitbucket has no implicit + // "authenticated user's namespace" the daemon could fall back to. + providersRequiringOwner: readonly string[]; +} + +export function getNewProjectBlocker( + state: NewProjectFormState, + capabilities: NewProjectCapabilities, +): NewProjectBlocker | null { + if (state.mode !== "open" && !capabilities.canScaffold) { + return "scaffold_unsupported"; + } + if (!state.directory.trim()) { + return "directory_required"; + } + if (state.mode === "open") { + return null; + } + if (state.mode === "clone") { + return state.cloneUrl.trim() ? null : "clone_url_required"; + } + + if (!state.folderName.trim()) { + return "folder_name_required"; + } + if (state.gitSetup !== "remote") { + return null; + } + const provider = state.remoteProvider?.trim() ?? ""; + if (!provider || !capabilities.remoteCapableProviders.includes(provider)) { + return "remote_provider_required"; + } + if (!resolveRemoteRepositoryName(state)) { + return "remote_name_required"; + } + if (capabilities.providersRequiringOwner.includes(provider) && !state.remoteOwner?.trim()) { + return "remote_owner_required"; + } + return null; +} + +// The remote repository defaults to the folder name — the overwhelmingly common +// case — and only diverges when the user edits it. +export function resolveRemoteRepositoryName(state: NewProjectFormState): string { + return state.remoteName.trim() || state.folderName.trim(); +} + +// Translates the form into the wire shape. Returns null when the form is not +// submittable, so callers can't accidentally send a half-filled request. +export function buildScaffoldGitRequest( + state: NewProjectFormState, + capabilities: NewProjectCapabilities, +): ProjectScaffoldGit | null { + if (state.mode === "open" || getNewProjectBlocker(state, capabilities)) { + return null; + } + + if (state.mode === "clone") { + return { kind: "clone", url: state.cloneUrl.trim() }; + } + + if (state.gitSetup === "none") { + return { kind: "none" }; + } + + const base = { + kind: "init" as const, + initialBranch: state.initialBranch.trim() || undefined, + addReadme: state.addReadme, + gitignoreTemplate: state.gitignoreTemplate ?? undefined, + // A remote implies a commit; the daemon enforces this too, but sending the + // honest intent keeps the step plan the client renders accurate up front. + initialCommit: true, + }; + + if (state.gitSetup !== "remote") { + return base; + } + + return { + ...base, + remote: { + providerId: state.remoteProvider!.trim(), + owner: state.remoteOwner?.trim() || null, + name: resolveRemoteRepositoryName(state), + description: state.remoteDescription.trim() || undefined, + visibility: state.remoteVisibility, + }, + }; +} + +// Adding a folder that is already a project is a no-op the daemon accepts +// silently — `findOrCreateProjectForDirectory` just returns the existing record, +// so the page would close as if it had done something. Catch it here instead and +// say so, the way New workspace refuses an occupied directory. +// +// Checked against the *target* path, so it also catches "create a folder that is +// already a project" and "clone into one" before any of it runs. +export function findDuplicateProjectPath(input: { + targetPath: string | null; + existingProjectPaths: readonly string[]; +}): string | null { + const target = normalizeWorkspacePath(input.targetPath); + if (!target) { + return null; + } + const match = input.existingProjectPaths.find( + (candidate) => normalizeWorkspacePath(candidate) === target, + ); + return match ?? null; +} + +// Which separator to join with. The client cannot know the daemon's platform, +// but the directory the user typed or browsed already tells us which convention +// that host writes — so match it rather than picking one. Joining a Windows +// directory with "/" produced `C:\Users\me\Projects/thing`, which reads as a +// bug even though the daemon would accept it. +export function detectPathSeparator(directory: string): "/" | "\\" { + if (directory.includes("\\")) { + return "\\"; + } + // A bare drive letter ("C:") has no separator to copy, but is still Windows. + return /^[a-zA-Z]:$/.test(directory.trim()) ? "\\" : "/"; +} + +// Preview of where the project will land, shown under the folder-name field so +// the user sees the full path before committing to it. +export function previewProjectPath(state: NewProjectFormState): string | null { + const directory = state.directory.trim(); + if (!directory) { + return null; + } + if (state.mode === "open") { + return directory; + } + const folderName = + state.folderName.trim() || + (state.mode === "clone" ? deriveFolderNameFromCloneUrl(state.cloneUrl) : ""); + if (!folderName) { + return null; + } + const trimmedDirectory = directory.replace(/[/\\]+$/, ""); + return `${trimmedDirectory}${detectPathSeparator(directory)}${folderName}`; +} + +// Mirrors the daemon's own derivation (deriveFolderNameFromRepositoryUrl) so +// the path preview matches where the clone actually lands. +export function deriveFolderNameFromCloneUrl(url: string): string { + const trimmed = url.trim().replace(/[/\\]+$/, ""); + if (!trimmed) { + return ""; + } + const lastSegment = trimmed.split(/[/\\:]/).pop() ?? ""; + return lastSegment.replace(/\.git$/i, ""); +} diff --git a/packages/app/src/screens/new-project/new-project-inputs.tsx b/packages/app/src/screens/new-project/new-project-inputs.tsx new file mode 100644 index 000000000..6766450b6 --- /dev/null +++ b/packages/app/src/screens/new-project/new-project-inputs.tsx @@ -0,0 +1,200 @@ +import { useCallback } from "react"; +import { Pressable, Text, TextInput, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Folder } from "@/components/icons/material-icons"; +import { Switch } from "@/components/ui/switch"; +import type { Theme } from "@/styles/theme"; + +// Inputs for the New project page. Deliberately not the settings form kit: this +// page reads like New workspace — a centred column with one prominent input — +// so the fields wear the composer's surface, not a settings row's. + +const ThemedInput = withUnistyles(TextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); + +const ThemedFolder = withUnistyles(Folder); +const folderIconMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, + size: theme.iconSize.sm, +}); + +interface NewProjectTextInputProps { + value: string; + onChangeText: (value: string) => void; + placeholder: string; + editable?: boolean; + autoFocus?: boolean; + // Renders at the composer's size; secondary fields use the smaller variant. + prominent?: boolean; + trailing?: React.ReactNode; + testID?: string; +} + +export function NewProjectTextInput({ + value, + onChangeText, + placeholder, + editable = true, + autoFocus = false, + prominent = false, + trailing, + testID, +}: NewProjectTextInputProps) { + return ( + + + {trailing} + + ); +} + +// A labelled secondary field. The label is a quiet caption above the input +// rather than a form-kit row, so a stack of them still reads as one column. +export function NewProjectField({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {label} + {children} + + ); +} + +export function NewProjectSwitchRow({ + label, + value, + onValueChange, + disabled, + testID, +}: { + label: string; + value: boolean; + onValueChange: (value: boolean) => void; + disabled?: boolean; + testID?: string; +}) { + return ( + + {label} + + + ); +} + +export function NewProjectSuggestionRow({ + path, + label, + onSelect, +}: { + path: string; + label: string; + onSelect: (path: string) => void; +}) { + const handlePress = useCallback(() => onSelect(path), [onSelect, path]); + const rowStyle = useCallback( + ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.suggestionRow, + (Boolean(hovered) || pressed) && styles.suggestionRowActive, + ], + [], + ); + + return ( + + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + inputBox: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + }, + inputBoxProminent: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.xl, + }, + input: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: { xs: theme.fontSize.base, md: theme.fontSize.sm }, + outlineStyle: "none", + } as object, + inputProminent: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: { xs: theme.fontSize.lg, md: theme.fontSize.base }, + outlineStyle: "none", + } as object, + field: { + gap: theme.spacing[1.5], + }, + fieldLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + switchRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + }, + switchLabel: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: { xs: theme.fontSize.base, md: theme.fontSize.sm }, + }, + suggestionRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + suggestionRowActive: { + backgroundColor: theme.colors.surface2, + }, + suggestionText: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/screens/new-project/new-project-picker-row.tsx b/packages/app/src/screens/new-project/new-project-picker-row.tsx new file mode 100644 index 000000000..a8068a5a4 --- /dev/null +++ b/packages/app/src/screens/new-project/new-project-picker-row.tsx @@ -0,0 +1,431 @@ +import { useCallback, useMemo } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { PROJECT_SCAFFOLD_GITIGNORE_TEMPLATE_IDS } from "@otto-code/protocol/messages"; +import type { HostingOwnerSummary, HostingRepositorySummary } from "@otto-code/protocol/messages"; +import { + Download, + EyeOff, + FolderGit2, + FolderOpen, + FolderPlus, + GitBranch, + Globe, + Groups, + PrivacyTip, + Server, +} from "@/components/icons/material-icons"; +import type { ComboboxOption } from "@/components/ui/combobox"; +import type { HostProfile } from "@/types/host-connection"; +import type { Theme } from "@/styles/theme"; +import { NewProjectBadgePicker } from "./new-project-badge-picker"; +import type { NewProjectFieldUpdate } from "./new-project-detail-fields"; +import type { NewProjectFormState, NewProjectGitSetup, NewProjectMode } from "./new-project-form"; + +// Pills above the input, mirroring New workspace's project / host / isolation / +// base row. Which pills appear follows the mode: a choice that cannot apply is +// hidden, never shown disabled. Usually one row; see the wrap note below for the +// single case that earns a second. + +// Gitignore is optional, so the picker needs an explicit "no template" entry. +const NO_GITIGNORE = "__none__"; + +// Wrapped once at module scope: withUnistyles per render would remount the icon. +const badgeIconMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, + size: theme.iconSize.sm, +}); +const ThemedFolderOpen = withUnistyles(FolderOpen); +const ThemedFolderPlus = withUnistyles(FolderPlus); +const ThemedDownload = withUnistyles(Download); +const ThemedServer = withUnistyles(Server); +const ThemedGitBranch = withUnistyles(GitBranch); +const ThemedEyeOff = withUnistyles(EyeOff); +const ThemedGlobe = withUnistyles(Globe); +const ThemedFolderGit2 = withUnistyles(FolderGit2); +const ThemedGroups = withUnistyles(Groups); +const ThemedPrivacyTip = withUnistyles(PrivacyTip); + +const modeIcons = { + open: , + create: , + clone: , +} satisfies Record; + +const hostIcon = ; +const gitSetupIcon = ; +const gitignoreIcon = ; +const providerIcon = ; +const repositoryIcon = ; +const ownerIcon = ; +const visibilityIcon = ; + +interface NewProjectPickerRowProps { + form: NewProjectFormState; + hosts: HostProfile[]; + selectedServerId: string; + onSelectHost: (serverId: string) => void; + connectedProviders: string[]; + owners: HostingOwnerSummary[]; + ownersLoading: boolean; + repositories: HostingRepositorySummary[]; + repositoriesLoading: boolean; + disabled: boolean; + onUpdate: NewProjectFieldUpdate; + onModeChange: (mode: NewProjectMode) => void; +} + +export function NewProjectPickerRow({ + form, + hosts, + selectedServerId, + onSelectHost, + connectedProviders, + owners, + ownersLoading, + repositories, + repositoriesLoading, + disabled, + onUpdate, + onModeChange, +}: NewProjectPickerRowProps) { + const { t } = useTranslation(); + + const handleModeSelect = useCallback( + (id: string) => onModeChange(id as NewProjectMode), + [onModeChange], + ); + const handleGitSetupSelect = useCallback( + (id: string) => onUpdate("gitSetup", id as NewProjectGitSetup), + [onUpdate], + ); + const handleProviderSelect = useCallback( + (id: string) => onUpdate("remoteProvider", id), + [onUpdate], + ); + const handleOwnerSelect = useCallback((id: string) => onUpdate("remoteOwner", id), [onUpdate]); + const handleVisibilitySelect = useCallback( + (id: string) => onUpdate("remoteVisibility", id as "private" | "public"), + [onUpdate], + ); + const handleRepositorySelect = useCallback((id: string) => onUpdate("cloneUrl", id), [onUpdate]); + const handleGitignoreSelect = useCallback( + (id: string) => onUpdate("gitignoreTemplate", id === NO_GITIGNORE ? null : id), + [onUpdate], + ); + + const modeOptions = useMemo( + () => [ + { id: "open", label: t("newProject.modes.open") }, + { id: "create", label: t("newProject.modes.create") }, + { id: "clone", label: t("newProject.modes.clone") }, + ], + [t], + ); + + const hostOptions = useMemo( + () => hosts.map((host) => ({ id: host.serverId, label: host.label })), + [hosts], + ); + + const gitSetupOptions = useMemo( + () => [ + { id: "none", label: t("newProject.gitSetup.none") }, + { id: "init", label: t("newProject.gitSetup.init") }, + // Creating a remote needs somewhere to create it; with no connected + // provider the choice is hidden rather than shown and then refused. + ...(connectedProviders.length > 0 + ? [{ id: "remote", label: t("newProject.gitSetup.remote") }] + : []), + ], + [connectedProviders.length, t], + ); + + const providerOptions = useMemo( + () => + connectedProviders.map((provider) => ({ + id: provider, + label: t(`newProject.providers.${provider}`, { defaultValue: provider }), + })), + [connectedProviders, t], + ); + + const ownerOptions = useMemo( + () => + owners.map((owner) => ({ + id: owner.id, + label: owner.label, + description: owner.id === owner.label ? undefined : owner.id, + })), + [owners], + ); + + const repositoryOptions = useMemo( + () => + repositories.map((repository) => ({ + id: repository.cloneUrl, + label: repository.fullName, + description: repository.description ?? undefined, + })), + [repositories], + ); + + const visibilityOptions = useMemo( + () => [ + { id: "private", label: t("newProject.visibility.private") }, + { id: "public", label: t("newProject.visibility.public") }, + ], + [t], + ); + + const gitignoreOptions = useMemo( + () => [ + { id: NO_GITIGNORE, label: t("newProject.fields.gitignorePlaceholder") }, + ...PROJECT_SCAFFOLD_GITIGNORE_TEMPLATE_IDS.map((id) => ({ + id, + label: t(`newProject.gitignore.${id}`, { defaultValue: id }), + })), + ], + [t], + ); + + const labelFor = (options: ComboboxOption[], id: string | null, fallback: string): string => + options.find((option) => option.id === id)?.label ?? fallback; + + const isCreate = form.mode === "create"; + const isClone = form.mode === "clone"; + const showRepoSetup = isCreate && form.gitSetup !== "none"; + const showRemote = isCreate && form.gitSetup === "remote"; + const selectedRepositoryLabel = labelFor( + repositoryOptions, + form.cloneUrl || null, + t("newProject.fields.repositoryPlaceholder"), + ); + + // Only "create + remote" earns a second row. It adds four pills on top of + // mode/host/git-setup/gitignore, which wraps arbitrarily mid-group and reads + // as noise. Clone adds just two (provider, repository) to a row that has at + // most mode and host — that fits, so it stays inline. + const remotePickers = ( + + ); + const showCloneInline = isClone && providerOptions.length > 0; + + return ( + + + + + {hosts.length > 1 ? ( + + ) : null} + + {isCreate ? ( + + ) : null} + + {showRepoSetup ? ( + + ) : null} + + {showCloneInline ? remotePickers : null} + + + {showRemote ? ( + + {remotePickers} + + ) : null} + + ); +} + +interface RemotePickersProps { + form: NewProjectFormState; + showRemote: boolean; + isClone: boolean; + providerOptions: ComboboxOption[]; + ownerOptions: ComboboxOption[]; + repositoryOptions: ComboboxOption[]; + visibilityOptions: ComboboxOption[]; + selectedRepositoryLabel: string; + ownersLoading: boolean; + repositoriesLoading: boolean; + disabled: boolean; + onSelectProvider: (id: string) => void; + onSelectOwner: (id: string) => void; + onSelectRepository: (id: string) => void; + onSelectVisibility: (id: string) => void; +} + +// The second row's contents. Split out so the row component itself stays a flat +// list of pills rather than a nest of mode conditionals. +function RemotePickers({ + form, + showRemote, + isClone, + providerOptions, + ownerOptions, + repositoryOptions, + visibilityOptions, + selectedRepositoryLabel, + ownersLoading, + repositoriesLoading, + disabled, + onSelectProvider, + onSelectOwner, + onSelectRepository, + onSelectVisibility, +}: RemotePickersProps) { + const { t } = useTranslation(); + const providerLabel = + providerOptions.find((option) => option.id === form.remoteProvider)?.label ?? + t("newProject.fields.providerPlaceholder"); + const visibilityLabel = + visibilityOptions.find((option) => option.id === form.remoteVisibility)?.label ?? ""; + + return ( + <> + {/* Clone offers the provider pill purely to browse your own repositories; + a pasted URL works with no provider connected at all. */} + + + {isClone && form.remoteProvider ? ( + + ) : null} + + {showRemote ? ( + <> + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + rows: { + gap: theme.spacing[2], + }, + row: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + // The badge adds its own left padding; offset it so the first pill's icon + // lands on the "New project" title's left edge. + paddingLeft: theme.spacing[4], + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/screens/new-project/use-new-project-hosting.ts b/packages/app/src/screens/new-project/use-new-project-hosting.ts new file mode 100644 index 000000000..3aa613bfb --- /dev/null +++ b/packages/app/src/screens/new-project/use-new-project-hosting.ts @@ -0,0 +1,122 @@ +import { useMemo } from "react"; +import type { HostingOwnerSummary, HostingRepositorySummary } from "@otto-code/protocol/messages"; +import { useFetchQuery } from "@/data/query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; + +// Host-level git-hosting reads the New project page needs before any repository +// exists on disk: which providers are usable, who you can create a repo under, +// and what you could clone. Split out of the screen so the screen renders and +// dispatches rather than orchestrating three async sources. + +// Providers Otto can create a remote repository on. Bitbucket Cloud has no +// implicit "authenticated user's namespace" — the workspace is part of the REST +// path — so it is the one provider that must be given an owner up front. +export const REMOTE_CAPABLE_PROVIDERS = ["github", "bitbucket-cloud"] as const; +export const PROVIDERS_REQUIRING_OWNER = ["bitbucket-cloud"] as const; + +export type RemoteCapableProvider = (typeof REMOTE_CAPABLE_PROVIDERS)[number]; + +export function isRemoteCapableProvider(value: string | null): value is RemoteCapableProvider { + return REMOTE_CAPABLE_PROVIDERS.includes(value as RemoteCapableProvider); +} + +const PROVIDER_STALE_MS = 60_000; + +export interface NewProjectHosting { + canScaffold: boolean; + connectedProviders: string[]; + owners: HostingOwnerSummary[]; + ownersLoading: boolean; + repositories: HostingRepositorySummary[]; + repositoriesLoading: boolean; +} + +export function useNewProjectHosting(input: { + serverId: string; + provider: string | null; + wantsOwners: boolean; + wantsRepositories: boolean; +}): NewProjectHosting { + const client = useHostRuntimeClient(input.serverId); + const isConnected = useHostRuntimeIsConnected(input.serverId); + + const canScaffold = useSessionStore( + (state) => state.sessions[input.serverId]?.serverInfo?.features?.projectScaffold === true, + ); + const hasGitProviders = useSessionStore( + (state) => state.sessions[input.serverId]?.serverInfo?.features?.gitHostingProviders === true, + ); + + // A provider the user has not signed into is hidden rather than offered and + // then failed at create time. + const connectedQuery = useFetchQuery({ + dataShape: "list", + staleTimeMs: PROVIDER_STALE_MS, + queryKey: ["new-project-connected-providers", input.serverId], + queryFn: async () => { + if (!client) return []; + const results = await Promise.all( + REMOTE_CAPABLE_PROVIDERS.map(async (provider) => { + const status = await client + .getHostingAuthStatus({ provider }) + .catch(() => ({ authenticated: false })); + return status.authenticated ? provider : null; + }), + ); + return results.filter((provider) => provider !== null); + }, + enabled: Boolean(client) && isConnected && hasGitProviders, + retry: false, + }); + + const ownersQuery = useFetchQuery({ + dataShape: "list", + staleTimeMs: PROVIDER_STALE_MS, + queryKey: ["new-project-owners", input.serverId, input.provider], + queryFn: async () => { + if (!client || !isRemoteCapableProvider(input.provider)) return []; + const payload = await client.listHostingOwners({ provider: input.provider }); + return payload.owners; + }, + enabled: + Boolean(client) && + isConnected && + input.wantsOwners && + isRemoteCapableProvider(input.provider), + retry: false, + }); + + const repositoriesQuery = useFetchQuery({ + dataShape: "list", + staleTimeMs: PROVIDER_STALE_MS, + queryKey: ["new-project-repositories", input.serverId, input.provider], + queryFn: async () => { + if (!client || !isRemoteCapableProvider(input.provider)) return []; + const payload = await client.listHostingRepositories({ + provider: input.provider, + limit: 100, + }); + return payload.repositories; + }, + enabled: + Boolean(client) && + isConnected && + input.wantsRepositories && + isRemoteCapableProvider(input.provider), + retry: false, + }); + + const connectedProviders = useMemo(() => connectedQuery.data ?? [], [connectedQuery.data]); + const owners = useMemo(() => ownersQuery.data ?? [], [ownersQuery.data]); + const repositories = useMemo(() => repositoriesQuery.data ?? [], [repositoriesQuery.data]); + + return { + canScaffold, + connectedProviders, + owners, + ownersLoading: ownersQuery.isFetching, + repositories, + repositoriesLoading: repositoriesQuery.isFetching, + }; +} diff --git a/packages/app/src/screens/new-workspace-empty.test.ts b/packages/app/src/screens/new-workspace-empty.test.ts new file mode 100644 index 000000000..28c5ddbe6 --- /dev/null +++ b/packages/app/src/screens/new-workspace-empty.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ComposerAttachment } from "@/attachments/types"; +import type { MessagePayload } from "@/composer/types"; +import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; + +function payload( + input: { text?: string; attachments?: ComposerAttachment[] } = {}, +): MessagePayload { + return { text: input.text ?? "", attachments: input.attachments ?? [], cwd: "/sample/repo" }; +} + +function createRecordingNavigate() { + const recorded: Array<{ serverId: string; workspaceId: string }> = []; + return { + recorded, + navigate: (serverId: string, workspaceId: string) => { + recorded.push({ serverId, workspaceId }); + }, + }; +} + +describe("runCreateEmptyWorkspace", () => { + it("creates a workspace without prompt or attachments and navigates to it", async () => { + const workspace = { id: "workspace-123" }; + const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + const { navigate, recorded } = createRecordingNavigate(); + + await runCreateEmptyWorkspace({ + payload: payload(), + ensureWorkspace, + serverId: "server-abc", + navigate, + }); + + expect(ensureWorkspace).toHaveBeenCalledOnce(); + expect(ensureWorkspace).toHaveBeenCalledWith({ + cwd: "/sample/repo", + prompt: "", + attachments: [], + withInitialAgent: false, + }); + expect(recorded).toEqual([{ serverId: "server-abc", workspaceId: "workspace-123" }]); + }); +}); + +describe("isEmptyWorkspaceSubmission", () => { + it("treats whitespace-only text with no attachments as empty, but any attachment as non-empty", () => { + const attachment: ComposerAttachment = { + kind: "image", + metadata: { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1", + createdAt: 0, + }, + }; + + expect(isEmptyWorkspaceSubmission(payload({ text: " \n\t " }))).toBe(true); + expect(isEmptyWorkspaceSubmission(payload({ attachments: [attachment] }))).toBe(false); + }); +}); diff --git a/packages/app/src/screens/new-workspace-empty.ts b/packages/app/src/screens/new-workspace-empty.ts new file mode 100644 index 000000000..63d67b0d9 --- /dev/null +++ b/packages/app/src/screens/new-workspace-empty.ts @@ -0,0 +1,30 @@ +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import type { MessagePayload } from "@/composer/types"; +import type { AgentAttachment } from "@otto-code/protocol/messages"; + +export function isEmptyWorkspaceSubmission(payload: MessagePayload): boolean { + return !payload.text.trim() && payload.attachments.length === 0; +} + +export interface CreateEmptyWorkspaceInput { + payload: MessagePayload; + ensureWorkspace: (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + withInitialAgent: boolean; + }) => Promise>; + serverId: string; + navigate: (serverId: string, workspaceId: string) => void; +} + +export async function runCreateEmptyWorkspace(input: CreateEmptyWorkspaceInput): Promise { + const { payload, ensureWorkspace, serverId, navigate } = input; + const ensuredWorkspace = await ensureWorkspace({ + cwd: payload.cwd, + prompt: "", + attachments: [], + withInitialAgent: false, + }); + navigate(serverId, ensuredWorkspace.id); +} diff --git a/packages/app/src/screens/new-workspace-existing-workspace.test.ts b/packages/app/src/screens/new-workspace-existing-workspace.test.ts new file mode 100644 index 000000000..cd5b5c967 --- /dev/null +++ b/packages/app/src/screens/new-workspace-existing-workspace.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { + findWorkspaceById, + findWorkspaceForDirectory, + findWorkspaceForProject, +} from "./new-workspace-existing-workspace"; + +const PROJECT_ROOT = "/repos/otto"; + +function workspace(overrides: Partial): WorkspaceDescriptor { + return { + id: "wks_1", + projectId: "proj_otto", + projectDisplayName: "otto", + projectCustomName: null, + projectRootPath: PROJECT_ROOT, + workspaceDirectory: PROJECT_ROOT, + projectKind: "git", + workspaceKind: "local", + name: "otto", + title: null, + status: "idle", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + ...overrides, + } as WorkspaceDescriptor; +} + +describe("findWorkspaceForDirectory", () => { + test("matches the workspace whose own directory is the one asked about", () => { + const root = workspace({ id: "wks_root" }); + const worktree = workspace({ + id: "wks_worktree", + workspaceDirectory: "/repos/otto/.otto/worktrees/feature", + workspaceKind: "worktree", + }); + + const found = findWorkspaceForDirectory({ + workspaces: [worktree, root], + directory: PROJECT_ROOT, + }); + + expect(found?.id).toBe("wks_root"); + }); + + test("does not match a worktree by its project root", () => { + const worktree = workspace({ + id: "wks_worktree", + workspaceDirectory: "/repos/otto/.otto/worktrees/feature", + workspaceKind: "worktree", + }); + + const found = findWorkspaceForDirectory({ + workspaces: [worktree], + directory: PROJECT_ROOT, + }); + + // Directory ownership is the question here, and the worktree does not own the + // root. Widening this would make the occupied-directory steer offer to open a + // workspace the daemon never refused on. + expect(found).toBeNull(); + }); + + test("normalizes separators so a Windows path matches its stored form", () => { + const found = findWorkspaceForDirectory({ + workspaces: [workspace({ id: "wks_root", workspaceDirectory: "C:/repos/otto" })], + directory: "C:\\repos\\otto", + }); + + expect(found?.id).toBe("wks_root"); + }); + + test("returns null for an empty directory rather than matching a directoryless workspace", () => { + const found = findWorkspaceForDirectory({ + workspaces: [workspace({ id: "wks_empty", workspaceDirectory: "" })], + directory: "", + }); + + expect(found).toBeNull(); + }); + + test("returns null when there are no workspaces at all", () => { + expect( + findWorkspaceForDirectory({ workspaces: undefined, directory: PROJECT_ROOT }), + ).toBeNull(); + }); +}); + +describe("findWorkspaceForProject", () => { + test("prefers the workspace rooted at the project root", () => { + const worktree = workspace({ + id: "wks_worktree", + workspaceDirectory: "/repos/otto/.otto/worktrees/feature", + workspaceKind: "worktree", + }); + const root = workspace({ id: "wks_root" }); + + const found = findWorkspaceForProject({ + workspaces: [worktree, root], + sourceDirectory: PROJECT_ROOT, + }); + + expect(found?.id).toBe("wks_root"); + }); + + test("falls back to a worktree when the project has nothing at its root", () => { + // The regression: reading a README used to create a whole extra workspace + // here, because the root was unoccupied so creation succeeded. + const worktree = workspace({ + id: "wks_worktree", + workspaceDirectory: "/repos/otto/.otto/worktrees/feature", + workspaceKind: "worktree", + }); + + const found = findWorkspaceForProject({ + workspaces: [worktree], + sourceDirectory: PROJECT_ROOT, + }); + + expect(found?.id).toBe("wks_worktree"); + }); + + test("skips workspaces being archived", () => { + const archiving = workspace({ + id: "wks_archiving", + workspaceDirectory: "/repos/otto/.otto/worktrees/dying", + archivingAt: "2026-07-29T00:00:00.000Z", + }); + const live = workspace({ + id: "wks_live", + workspaceDirectory: "/repos/otto/.otto/worktrees/feature", + }); + + const found = findWorkspaceForProject({ + workspaces: [archiving, live], + sourceDirectory: PROJECT_ROOT, + }); + + expect(found?.id).toBe("wks_live"); + }); + + test("ignores workspaces belonging to a different project", () => { + const other = workspace({ + id: "wks_other", + projectId: "proj_other", + projectRootPath: "/repos/other", + workspaceDirectory: "/repos/other", + }); + + const found = findWorkspaceForProject({ + workspaces: [other], + sourceDirectory: PROJECT_ROOT, + }); + + expect(found).toBeNull(); + }); + + test("returns null for an empty source directory", () => { + expect( + findWorkspaceForProject({ workspaces: [workspace({})], sourceDirectory: "" }), + ).toBeNull(); + }); +}); + +describe("findWorkspaceById", () => { + test("resolves a workspace the steer already identified", () => { + const found = findWorkspaceById({ + workspaces: [workspace({ id: "wks_a" }), workspace({ id: "wks_b" })], + workspaceId: "wks_b", + }); + + expect(found?.id).toBe("wks_b"); + }); + + test("returns null when the workspace is gone", () => { + const found = findWorkspaceById({ + workspaces: [workspace({ id: "wks_a" })], + workspaceId: "wks_gone", + }); + + expect(found).toBeNull(); + }); +}); diff --git a/packages/app/src/screens/new-workspace-existing-workspace.ts b/packages/app/src/screens/new-workspace-existing-workspace.ts new file mode 100644 index 000000000..9cab85dfd --- /dev/null +++ b/packages/app/src/screens/new-workspace-existing-workspace.ts @@ -0,0 +1,89 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; + +/** + * Finding the workspace that is *already there* is the shared precondition of + * every "you don't need a new workspace for this" path on the New Workspace + * screen: the occupied-directory steer, and View Documentation. Both used to ask + * the same narrow question (which workspace's own directory is this?) and both + * were wrong for the same reason, so the question lives here once. + * + * Pure over an iterable of descriptors rather than reaching into the session + * store, so the matching rules are unit-testable without a store. + */ + +/** + * The live workspace whose *own* directory is `directory`. + * + * This is exactly the question the daemon's occupied-directory refusal asks: one + * directory backs at most one visible workspace, so at most one can match. Use + * this when the directory itself is the thing being contended. + */ +export function findWorkspaceForDirectory(input: { + workspaces: Iterable | undefined; + directory: string; +}): WorkspaceDescriptor | null { + const normalizedDirectory = normalizeWorkspacePath(input.directory); + if (!normalizedDirectory) { + return null; + } + for (const workspace of input.workspaces ?? []) { + if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedDirectory) { + return workspace; + } + } + return null; +} + +/** + * Any live workspace belonging to the project rooted at `sourceDirectory`, + * preferring one whose own directory *is* that root. + * + * Opening a file never justifies a workspace of its own, and matching on the + * root alone is not enough to avoid one: a project whose only workspace is a + * worktree has no workspace at the root, so the narrow lookup misses, creation + * then succeeds (nothing occupies the root), and the user gets a second + * workspace as a side effect of reading a README. Falling back to the worktree + * opens the file in a workspace they already have, which is what they asked for. + * + * Workspaces mid-teardown are skipped: navigating into one that is being + * archived is a dead end. + */ +export function findWorkspaceForProject(input: { + workspaces: Iterable | undefined; + sourceDirectory: string; +}): WorkspaceDescriptor | null { + const normalizedRoot = normalizeWorkspacePath(input.sourceDirectory); + if (!normalizedRoot) { + return null; + } + let sameProjectFallback: WorkspaceDescriptor | null = null; + for (const workspace of input.workspaces ?? []) { + if (workspace.archivingAt) { + continue; + } + if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedRoot) { + return workspace; + } + if ( + !sameProjectFallback && + normalizeWorkspacePath(workspace.projectRootPath) === normalizedRoot + ) { + sameProjectFallback = workspace; + } + } + return sameProjectFallback; +} + +/** Re-resolves a workspace the steer already identified, by id. */ +export function findWorkspaceById(input: { + workspaces: Iterable | undefined; + workspaceId: string; +}): WorkspaceDescriptor | null { + for (const workspace of input.workspaces ?? []) { + if (workspace.id === input.workspaceId) { + return workspace; + } + } + return null; +} diff --git a/packages/app/src/screens/new-workspace-fork-context.test.ts b/packages/app/src/screens/new-workspace-fork-context.test.ts new file mode 100644 index 000000000..d4a6368f0 --- /dev/null +++ b/packages/app/src/screens/new-workspace-fork-context.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { AgentAttachment } from "@otto-code/protocol/messages"; +import { + getWorkspaceNamingAttachments, + remapDraftCwdToWorkspace, +} from "./new-workspace-fork-context"; + +describe("remapDraftCwdToWorkspace", () => { + it("preserves a Windows subdirectory when source path casing differs", () => { + expect( + remapDraftCwdToWorkspace({ + cwd: "c:\\Repo\\packages\\app", + sourceDirectory: "C:\\Repo", + workspaceDirectory: "D:\\Worktrees\\fork", + }), + ).toBe("D:\\Worktrees\\fork\\packages\\app"); + }); + + it("falls back to the workspace root when the cwd is outside the source directory", () => { + expect( + remapDraftCwdToWorkspace({ + cwd: "/other/repo/packages/app", + sourceDirectory: "/repo", + workspaceDirectory: "/worktrees/fork", + }), + ).toBe("/worktrees/fork"); + }); +}); + +describe("getWorkspaceNamingAttachments", () => { + it("removes full chat history from workspace naming context", () => { + const chatHistory = { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Long prior conversation", + } satisfies AgentAttachment; + const prContext = { + type: "github_pr", + mimeType: "application/github-pr", + number: 1788, + title: "Fork assistant turns into new drafts", + url: "https://github.com/otto-code-ai/otto-code/pull/1788", + } satisfies AgentAttachment; + + expect(getWorkspaceNamingAttachments([chatHistory, prContext])).toEqual([prContext]); + }); +}); diff --git a/packages/app/src/screens/new-workspace-fork-context.ts b/packages/app/src/screens/new-workspace-fork-context.ts new file mode 100644 index 000000000..5ca847d61 --- /dev/null +++ b/packages/app/src/screens/new-workspace-fork-context.ts @@ -0,0 +1,50 @@ +import type { AgentAttachment } from "@otto-code/protocol/messages"; + +function isLikelyWindowsPath(path: string): boolean { + return /^[a-zA-Z]:\//.test(path); +} + +function isChatHistoryTextAttachment(attachment: AgentAttachment): boolean { + return attachment.type === "text" && attachment.contextKind === "chat_history"; +} + +export function getWorkspaceNamingAttachments( + attachments: readonly AgentAttachment[], +): AgentAttachment[] { + return attachments.filter((attachment) => !isChatHistoryTextAttachment(attachment)); +} + +export function remapDraftCwdToWorkspace(input: { + cwd: string; + sourceDirectory?: string | null; + workspaceDirectory: string; +}): string { + const cwd = input.cwd.trim(); + const sourceDirectory = input.sourceDirectory?.trim(); + const workspaceDirectory = input.workspaceDirectory.trim(); + if (!cwd || !sourceDirectory) { + return workspaceDirectory; + } + const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedSource = sourceDirectory.replace(/\\/g, "/").replace(/\/+$/, ""); + const compareCaseInsensitively = + isLikelyWindowsPath(normalizedCwd) || isLikelyWindowsPath(normalizedSource); + const comparableCwd = compareCaseInsensitively ? normalizedCwd.toLowerCase() : normalizedCwd; + const comparableSource = compareCaseInsensitively + ? normalizedSource.toLowerCase() + : normalizedSource; + if (comparableCwd === comparableSource) { + return workspaceDirectory; + } + const relativePath = comparableCwd.startsWith(`${comparableSource}/`) + ? normalizedCwd.slice(normalizedSource.length + 1) + : ""; + if (!relativePath) { + return workspaceDirectory; + } + const separator = + workspaceDirectory.includes("\\") && !workspaceDirectory.includes("/") ? "\\" : "/"; + return [workspaceDirectory.replace(/[\\/]+$/, ""), ...relativePath.split("/")] + .filter(Boolean) + .join(separator); +} diff --git a/packages/app/src/screens/new-workspace-initial-context.test.ts b/packages/app/src/screens/new-workspace-initial-context.test.ts new file mode 100644 index 000000000..60a272b23 --- /dev/null +++ b/packages/app/src/screens/new-workspace-initial-context.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; +import type { HostProjectListItem } from "@/projects/host-projects"; +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import { + resolveNewWorkspaceAutomaticServerId, + resolveNewWorkspaceInitialServerId, +} from "./new-workspace-initial-context"; + +function projectFor(serverId: string, key = "project"): HostProjectListItem { + return { + projectKey: key, + projectName: key, + projectKind: "git", + iconWorkingDir: `/work/${key}`, + hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }], + workspaceKeys: [], + }; +} + +function statuses( + entries: Record, +): ReadonlyMap { + return new Map(Object.entries(entries)); +} + +function multiplicity(entries: Record = {}): ReadonlyMap { + return new Map(Object.entries(entries)); +} + +describe("resolveNewWorkspaceInitialServerId", () => { + it("prefers explicit route host context over online-host fallback", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online"], + routeServerId: "offline", + lastActiveProject: null, + projects: [projectFor("online")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("offline"); + }); + + it("prefers the sole online host over a stale offline project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online"], + routeServerId: null, + lastActiveProject: projectFor("offline"), + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online"); + + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("online")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online"); + }); + + it("uses the last active project when there is no sole online host", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "other"], + routeServerId: null, + lastActiveProject: projectFor("offline"), + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ offline: "offline", other: "offline" }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("offline"); + }); + + it("prefers a connecting project host over a stale offline last active project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "connecting"], + routeServerId: null, + lastActiveProject: projectFor("offline", "remembered"), + projects: [projectFor("offline", "remembered"), projectFor("connecting", "current")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + connecting: "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("connecting"); + }); + + it("prefers the online last active project over another hydrated online project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["host-a", "host-b"], + routeServerId: null, + lastActiveProject: projectFor("host-b", "remembered"), + projects: [projectFor("host-a")], + hostConnectionStatusByServerId: statuses({ + "host-a": "online", + "host-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("host-b"); + }); + + it("falls back to the only online host even before projects have hydrated", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline-a", "online", "offline-b"], + routeServerId: null, + lastActiveProject: null, + projects: [], + hostConnectionStatusByServerId: statuses({ + "offline-a": "offline", + online: "online", + "offline-b": "offline", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online"); + }); + + it("prefers an online host over the only cached offline project", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "online-a", "online-b"], + routeServerId: null, + lastActiveProject: projectFor("offline"), + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + "online-a": "online", + "online-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("online-a"); + }); + + it("prefers the first online project host over an empty online host", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["empty-online", "project-online-a", "project-online-b"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("project-online-a", "a"), projectFor("project-online-b", "b")], + hostConnectionStatusByServerId: statuses({ + "empty-online": "online", + "project-online-a": "online", + "project-online-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("project-online-a"); + }); + + it("uses the only host with selectable projects even before runtime status is online", () => { + expect( + resolveNewWorkspaceInitialServerId({ + allServerIds: ["offline", "connected"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("connected")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + connected: "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + }), + ).toBe("connected"); + }); +}); + +describe("resolveNewWorkspaceAutomaticServerId", () => { + it("keeps a usable automatic host stable when the computed default changes", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["host-a", "host-b"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("host-a"), projectFor("host-b")], + hostConnectionStatusByServerId: statuses({ "host-a": "online", "host-b": "online" }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "host-a", + nextServerId: "host-b", + }), + ).toBe("host-a"); + }); + + it("switches to the remembered online host after it hydrates", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["host-a", "host-b"], + routeServerId: null, + lastActiveProject: projectFor("host-b", "remembered"), + projects: [projectFor("host-a"), projectFor("host-b", "remembered")], + hostConnectionStatusByServerId: statuses({ + "host-a": "online", + "host-b": "online", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "host-a", + nextServerId: "host-b", + }), + ).toBe("host-b"); + }); + + it("switches from an offline automatic host to the online default", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["offline", "online"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("offline")], + hostConnectionStatusByServerId: statuses({ offline: "offline", online: "online" }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "offline", + nextServerId: "online", + }), + ).toBe("online"); + }); + + it("switches from an offline automatic host to a connecting default with projects", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["offline", "connecting"], + routeServerId: null, + lastActiveProject: projectFor("offline", "remembered"), + projects: [projectFor("offline", "remembered"), projectFor("connecting", "current")], + hostConnectionStatusByServerId: statuses({ + offline: "offline", + connecting: "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "offline", + nextServerId: "connecting", + }), + ).toBe("connecting"); + }); + + it("switches to the default when the current automatic host has no selectable projects", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["empty", "with-project"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("with-project")], + hostConnectionStatusByServerId: statuses({ + empty: "connecting", + "with-project": "connecting", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "empty", + nextServerId: "with-project", + }), + ).toBe("with-project"); + }); + + it("does not switch from an online host to an offline cached project", () => { + expect( + resolveNewWorkspaceAutomaticServerId({ + allServerIds: ["online-empty", "offline-project"], + routeServerId: null, + lastActiveProject: null, + projects: [projectFor("offline-project")], + hostConnectionStatusByServerId: statuses({ + "online-empty": "online", + "offline-project": "offline", + }), + workspaceMultiplicityByServerId: multiplicity(), + currentServerId: "online-empty", + nextServerId: "offline-project", + }), + ).toBe("online-empty"); + }); +}); diff --git a/packages/app/src/screens/new-workspace-initial-context.ts b/packages/app/src/screens/new-workspace-initial-context.ts new file mode 100644 index 000000000..4ed87931e --- /dev/null +++ b/packages/app/src/screens/new-workspace-initial-context.ts @@ -0,0 +1,254 @@ +import { + canCreateWorkspaceForHostProject, + type HostProjectListItem, +} from "@/projects/host-projects"; +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; + +export interface NewWorkspaceInitialServerInput { + allServerIds: readonly string[]; + routeServerId: string | null | undefined; + lastActiveProject: HostProjectListItem | null; + projects: readonly HostProjectListItem[]; + hostConnectionStatusByServerId: ReadonlyMap; + workspaceMultiplicityByServerId: ReadonlyMap; +} + +function knownServerId(serverIds: ReadonlySet, serverId: string | null | undefined) { + const normalized = serverId?.trim() ?? ""; + return normalized && serverIds.has(normalized) ? normalized : null; +} + +function supportsAllProjects( + workspaceMultiplicityByServerId: ReadonlyMap, + serverId: string, +) { + return workspaceMultiplicityByServerId.get(serverId) === true; +} + +function getProjectForServer(input: { + candidate: HostProjectListItem; + projects: readonly HostProjectListItem[]; + serverId: string; +}) { + return ( + input.projects.find( + (project) => + project.projectKey === input.candidate.projectKey && + project.hosts.some((host) => host.serverId === input.serverId), + ) ?? input.candidate + ); +} + +function canUseProjectForServer(input: { + project: HostProjectListItem; + projects: readonly HostProjectListItem[]; + serverId: string; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + const project = getProjectForServer({ + candidate: input.project, + projects: input.projects, + serverId: input.serverId, + }); + return canCreateWorkspaceForHostProject({ + project, + serverId: input.serverId, + allowAllProjects: supportsAllProjects(input.workspaceMultiplicityByServerId, input.serverId), + }); +} + +function findLastActiveProjectServerId(input: { + serverIds: ReadonlySet; + lastActiveProject: HostProjectListItem | null; + projects: readonly HostProjectListItem[]; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + if (!input.lastActiveProject) { + return null; + } + + for (const host of input.lastActiveProject.hosts) { + if (!input.serverIds.has(host.serverId)) { + continue; + } + if ( + canUseProjectForServer({ + project: input.lastActiveProject, + projects: input.projects, + serverId: host.serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }) + ) { + return host.serverId; + } + } + + return null; +} + +function hasSelectableProject(input: { + projects: readonly HostProjectListItem[]; + serverId: string; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + return input.projects.some((project) => + canUseProjectForServer({ + project, + projects: input.projects, + serverId: input.serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + ); +} + +function isOnline( + statuses: ReadonlyMap, + serverId: string, +): boolean { + return statuses.get(serverId) === "online"; +} + +function isKnownUnreachable( + statuses: ReadonlyMap, + serverId: string, +): boolean { + const status = statuses.get(serverId); + return status === "offline" || status === "error"; +} + +export function resolveNewWorkspaceInitialServerId(input: NewWorkspaceInitialServerInput): string { + const serverIds = new Set(input.allServerIds); + const routeServerId = knownServerId(serverIds, input.routeServerId); + if (routeServerId) { + return routeServerId; + } + + const onlineServerIds = input.allServerIds.filter((serverId) => + isOnline(input.hostConnectionStatusByServerId, serverId), + ); + const onlineServerIdsWithProjects = onlineServerIds.filter((serverId) => + hasSelectableProject({ + projects: input.projects, + serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + ); + const serverIdsWithProjects = input.allServerIds.filter((serverId) => + hasSelectableProject({ + projects: input.projects, + serverId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + ); + + const lastActiveProjectServerId = findLastActiveProjectServerId({ + serverIds, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + if ( + lastActiveProjectServerId && + isOnline(input.hostConnectionStatusByServerId, lastActiveProjectServerId) + ) { + return lastActiveProjectServerId; + } + + if (onlineServerIdsWithProjects.length > 0) { + return onlineServerIdsWithProjects[0] ?? ""; + } + if (onlineServerIds.length === 1) { + return onlineServerIds[0] ?? ""; + } + + if (onlineServerIds.length > 0) { + return onlineServerIds[0] ?? ""; + } + + const reachableServerIdsWithProjects = serverIdsWithProjects.filter( + (serverId) => !isKnownUnreachable(input.hostConnectionStatusByServerId, serverId), + ); + if (reachableServerIdsWithProjects.length === 1) { + return reachableServerIdsWithProjects[0] ?? ""; + } + + if (lastActiveProjectServerId) { + return lastActiveProjectServerId; + } + + if (serverIdsWithProjects.length === 1) { + return serverIdsWithProjects[0] ?? ""; + } + + return input.allServerIds[0] ?? ""; +} + +export function resolveNewWorkspaceAutomaticServerId( + input: NewWorkspaceInitialServerInput & { + currentServerId: string | null | undefined; + nextServerId: string | null | undefined; + }, +): string { + const serverIds = new Set(input.allServerIds); + const currentServerId = knownServerId(serverIds, input.currentServerId); + const nextServerId = knownServerId(serverIds, input.nextServerId) ?? input.allServerIds[0] ?? ""; + if (!currentServerId || currentServerId === nextServerId) { + return nextServerId; + } + + if ( + isOnline(input.hostConnectionStatusByServerId, nextServerId) && + !isOnline(input.hostConnectionStatusByServerId, currentServerId) + ) { + return nextServerId; + } + + const routeServerId = knownServerId(serverIds, input.routeServerId); + if (routeServerId === nextServerId) { + return nextServerId; + } + + const lastActiveProjectServerId = findLastActiveProjectServerId({ + serverIds, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + const hasOnlineServer = input.allServerIds.some((serverId) => + isOnline(input.hostConnectionStatusByServerId, serverId), + ); + if ( + lastActiveProjectServerId === nextServerId && + (isOnline(input.hostConnectionStatusByServerId, nextServerId) || !hasOnlineServer) + ) { + return nextServerId; + } + + const currentHasProject = hasSelectableProject({ + projects: input.projects, + serverId: currentServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + const nextHasProject = hasSelectableProject({ + projects: input.projects, + serverId: nextServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }); + if ( + isKnownUnreachable(input.hostConnectionStatusByServerId, currentServerId) && + nextHasProject && + !isKnownUnreachable(input.hostConnectionStatusByServerId, nextServerId) + ) { + return nextServerId; + } + if ( + !currentHasProject && + nextHasProject && + (!isOnline(input.hostConnectionStatusByServerId, currentServerId) || + isOnline(input.hostConnectionStatusByServerId, nextServerId)) + ) { + return nextServerId; + } + + return currentServerId; +} diff --git a/packages/app/src/screens/new-workspace-occupied-directory.test.ts b/packages/app/src/screens/new-workspace-occupied-directory.test.ts new file mode 100644 index 000000000..48495812b --- /dev/null +++ b/packages/app/src/screens/new-workspace-occupied-directory.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ConfirmDialogChoice, ConfirmDialogResult } from "@/utils/confirm-dialog"; +import { + isWorkspaceDirectoryOccupiedError, + runOccupiedDirectorySteer, + WorkspaceDirectoryOccupiedClientError, +} from "./new-workspace-occupied-directory"; + +const LABELS = { + title: "This folder already has a workspace", + openExisting: "Open it", + createWorktree: "Create a worktree", +}; + +const DIRECTORY = "/repos/otto"; + +function occupiedError(message = 'This directory already backs the workspace "Qwen Development".') { + return new WorkspaceDirectoryOccupiedClientError(message, DIRECTORY); +} + +function confirmReturning(choice: ConfirmDialogChoice) { + return vi.fn( + async (): Promise => ({ + confirmed: choice === "confirm", + checkboxChecked: false, + choice, + }), + ); +} + +function harness(overrides: Partial[0]> = {}) { + const openExistingWorkspace = vi.fn(async () => {}); + const createWorktreeInstead = vi.fn(async () => {}); + const onError = vi.fn(); + return { + openExistingWorkspace, + createWorktreeInstead, + onError, + input: { + error: occupiedError(), + labels: LABELS, + findExistingWorkspaceId: () => "wks_existing", + confirm: confirmReturning("cancel"), + openExistingWorkspace, + createWorktreeInstead, + onError, + ...overrides, + }, + }; +} + +describe("runOccupiedDirectorySteer", () => { + test("opens the occupying workspace when the user picks the primary action", async () => { + const h = harness({ confirm: confirmReturning("confirm") }); + + const outcome = await runOccupiedDirectorySteer(h.input); + + expect(outcome).toBe("opened_existing"); + expect(h.openExistingWorkspace).toHaveBeenCalledWith("wks_existing"); + expect(h.createWorktreeInstead).not.toHaveBeenCalled(); + expect(h.onError).not.toHaveBeenCalled(); + }); + + test("waits for the replayed submission before reporting the outcome", async () => { + // The primary action starts the user's chat in the occupying workspace, so it + // is async. Returning before it settles would strand a failure unreported. + const order: string[] = []; + const h = harness({ + confirm: confirmReturning("confirm"), + openExistingWorkspace: async () => { + await Promise.resolve(); + order.push("submitted"); + }, + }); + + const outcome = await runOccupiedDirectorySteer(h.input); + order.push("returned"); + + expect(outcome).toBe("opened_existing"); + expect(order).toEqual(["submitted", "returned"]); + }); + + test("surfaces a failed replay into the existing workspace", async () => { + const h = harness({ + confirm: confirmReturning("confirm"), + openExistingWorkspace: async () => { + throw new Error("Select a model"); + }, + }); + + const outcome = await runOccupiedDirectorySteer(h.input); + + expect(outcome).toBe("unresolved"); + expect(h.onError).toHaveBeenCalledWith("Select a model"); + }); + + test("retries as a worktree when the user picks the alternate action", async () => { + const h = harness({ confirm: confirmReturning("alternate") }); + + const outcome = await runOccupiedDirectorySteer(h.input); + + expect(outcome).toBe("created_worktree"); + expect(h.createWorktreeInstead).toHaveBeenCalledTimes(1); + expect(h.openExistingWorkspace).not.toHaveBeenCalled(); + expect(h.onError).not.toHaveBeenCalled(); + }); + + test("does nothing on cancel — no navigation, no retry, no error surface", async () => { + const h = harness({ confirm: confirmReturning("cancel") }); + + const outcome = await runOccupiedDirectorySteer(h.input); + + expect(outcome).toBe("cancelled"); + expect(h.openExistingWorkspace).not.toHaveBeenCalled(); + expect(h.createWorktreeInstead).not.toHaveBeenCalled(); + expect(h.onError).not.toHaveBeenCalled(); + }); + + test("offers the daemon's message and the steer labels to the dialog", async () => { + const confirm = confirmReturning("cancel"); + const h = harness({ confirm }); + + await runOccupiedDirectorySteer(h.input); + + expect(confirm).toHaveBeenCalledWith({ + title: LABELS.title, + message: 'This directory already backs the workspace "Qwen Development".', + confirmLabel: LABELS.openExisting, + alternateLabel: LABELS.createWorktree, + }); + }); + + test("falls back to the plain error when the occupant cannot be resolved", async () => { + const confirm = confirmReturning("confirm"); + const h = harness({ findExistingWorkspaceId: () => null, confirm }); + + const outcome = await runOccupiedDirectorySteer(h.input); + + expect(outcome).toBe("unresolved"); + // Nothing to steer to, so we must not open a dialog offering to open it. + expect(confirm).not.toHaveBeenCalled(); + expect(h.onError).toHaveBeenCalledWith( + 'This directory already backs the workspace "Qwen Development".', + ); + }); + + test("surfaces a failed worktree retry instead of swallowing it", async () => { + const h = harness({ + confirm: confirmReturning("alternate"), + createWorktreeInstead: async () => { + throw new Error("Failed to create worktree"); + }, + }); + + const outcome = await runOccupiedDirectorySteer(h.input); + + expect(outcome).toBe("unresolved"); + expect(h.onError).toHaveBeenCalledWith("Failed to create worktree"); + }); + + test("the typed error is distinguishable from an ordinary create failure", () => { + expect(isWorkspaceDirectoryOccupiedError(occupiedError())).toBe(true); + expect(isWorkspaceDirectoryOccupiedError(new Error("Failed to create worktree"))).toBe(false); + expect(isWorkspaceDirectoryOccupiedError(null)).toBe(false); + expect(occupiedError().sourceDirectory).toBe(DIRECTORY); + }); +}); diff --git a/packages/app/src/screens/new-workspace-occupied-directory.ts b/packages/app/src/screens/new-workspace-occupied-directory.ts new file mode 100644 index 000000000..bb73a7306 --- /dev/null +++ b/packages/app/src/screens/new-workspace-occupied-directory.ts @@ -0,0 +1,112 @@ +import type { ConfirmDialogInput, ConfirmDialogResult } from "@/utils/confirm-dialog"; + +/** Wire errorCode the daemon returns when a visible workspace already backs the cwd. */ +export const WORKSPACE_DIRECTORY_OCCUPIED_CODE = "workspace_directory_occupied"; + +/** + * Thrown instead of a bare `Error` when `workspace.create` is refused because the + * directory is occupied, so the submit handler can offer a way forward rather + * than surfacing a dead-end toast. Carries the directory because the occupying + * workspace is resolved client-side (it is visible, so it is already in the + * sidebar list) — the daemon does not send its id. + */ +export class WorkspaceDirectoryOccupiedClientError extends Error { + readonly sourceDirectory: string; + + constructor(message: string, sourceDirectory: string) { + super(message); + this.name = "WorkspaceDirectoryOccupiedClientError"; + this.sourceDirectory = sourceDirectory; + } +} + +export function isWorkspaceDirectoryOccupiedError( + error: unknown, +): error is WorkspaceDirectoryOccupiedClientError { + return error instanceof WorkspaceDirectoryOccupiedClientError; +} + +export interface OccupiedDirectorySteerLabels { + title: string; + openExisting: string; + createWorktree: string; +} + +export interface RunOccupiedDirectorySteerInput { + error: WorkspaceDirectoryOccupiedClientError; + labels: OccupiedDirectorySteerLabels; + /** Resolves the visible workspace already backing `sourceDirectory`, if known. */ + findExistingWorkspaceId: (directory: string) => string | null; + confirm: (input: ConfirmDialogInput) => Promise; + /** + * Opens the occupying workspace *and* starts the chat the user just set up in + * it. Not a bare navigation: the user filled in a prompt, a model and a + * personality before submitting, and the workspace being pre-existing is no + * reason to throw that away. Mirrors `createWorktreeInstead`, which replays the + * same submission down the other branch of this dialog. + */ + openExistingWorkspace: (workspaceId: string) => Promise | void; + /** Re-runs creation forcing worktree isolation. */ + createWorktreeInstead: () => Promise; + /** Fallback surface when there is nothing to steer to, and for retry failures. */ + onError: (message: string) => void; +} + +export type OccupiedDirectorySteerOutcome = + | "opened_existing" + | "created_worktree" + | "cancelled" + | "unresolved"; + +/** + * One directory is one live workspace, so the daemon refuses a second visible + * workspace on an occupied one. Refusing is right; refusing *silently* is not — + * without this the user gets the daemon's message and no way to act on it, which + * is why every internal caller grew its own reuse workaround instead. + * + * Offers the two things they actually meant: open the workspace that is already + * there, or take the worktree that gives them the independent branch they were + * implicitly asking for. Falls back to the plain error only when the occupying + * workspace cannot be resolved, since there is nothing to open in that case. + * + * Both branches carry the user's submission through. Neither is a detour that + * silently discards it and leaves them staring at an empty composer. + */ +export async function runOccupiedDirectorySteer( + input: RunOccupiedDirectorySteerInput, +): Promise { + const existingWorkspaceId = input.findExistingWorkspaceId(input.error.sourceDirectory); + if (!existingWorkspaceId) { + input.onError(input.error.message); + return "unresolved"; + } + + const result = await input.confirm({ + title: input.labels.title, + message: input.error.message, + confirmLabel: input.labels.openExisting, + alternateLabel: input.labels.createWorktree, + }); + + if (result.choice === "confirm") { + try { + await input.openExistingWorkspace(existingWorkspaceId); + return "opened_existing"; + } catch (error) { + input.onError(error instanceof Error ? error.message : String(error)); + return "unresolved"; + } + } + + if (result.choice === "alternate") { + try { + await input.createWorktreeInstead(); + return "created_worktree"; + } catch (error) { + input.onError(error instanceof Error ? error.message : String(error)); + return "unresolved"; + } + } + + return "cancelled"; +} diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts new file mode 100644 index 000000000..6b80c6ebb --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { GitHubSearchItem } from "@otto-code/protocol/messages"; +import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item"; + +const prItem: GitHubSearchItem = { + kind: "pr", + number: 42, + title: "Add picker", + url: "https://example.com/pull/42", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature/picker", +}; + +describe("pickerItemToCheckoutRequest", () => { + it("returns undefined for no selection (null)", () => { + expect(pickerItemToCheckoutRequest(null)).toBeUndefined(); + }); + + it("maps a branch row to branch-off with the branch name", () => { + const item: PickerItem = { kind: "branch", name: "dev" }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "branch-off", + refName: "dev", + }); + }); + + it("maps a github-pr row to checkout using the head ref and pr number", () => { + const item: PickerItem = { + kind: "github-pr", + item: prItem, + }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "checkout", + refName: "feature/picker", + githubPrNumber: 42, + }); + }); + + it("handles a github-pr with a null baseRef", () => { + const item: PickerItem = { + kind: "github-pr", + item: { + ...prItem, + number: 7, + title: "Orphan branch", + baseRefName: null, + headRefName: "orphan", + }, + }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "checkout", + refName: "orphan", + githubPrNumber: 7, + }); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts new file mode 100644 index 000000000..9944c56cf --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -0,0 +1,32 @@ +import type { CreateOttoWorktreeInput } from "@otto-code/client/internal/daemon-client"; +import type { GitHubSearchItem } from "@otto-code/protocol/messages"; + +export type PickerItem = + | { kind: "branch"; name: string } + | { + kind: "github-pr"; + item: GitHubSearchItem; + }; + +export type PickerCheckoutRequest = Pick< + CreateOttoWorktreeInput, + "action" | "refName" | "githubPrNumber" +>; + +export function pickerItemToCheckoutRequest( + item: PickerItem | null, +): PickerCheckoutRequest | undefined { + if (!item) return undefined; + switch (item.kind) { + case "branch": + return { action: "branch-off", refName: item.name }; + case "github-pr": { + const headRefName = item.item.headRefName?.trim(); + return { + action: "checkout", + ...(headRefName ? { refName: headRefName } : {}), + githubPrNumber: item.item.number, + }; + } + } +} diff --git a/packages/app/src/screens/new-workspace-picker-state.test.ts b/packages/app/src/screens/new-workspace-picker-state.test.ts new file mode 100644 index 000000000..784e000a4 --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-state.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { GitHubSearchItem } from "@otto-code/protocol/messages"; +import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; + +function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem { + return { + kind: "pr", + number, + title, + url: `https://example.com/pull/${number}`, + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName, + }; +} + +function prAttachment( + item: GitHubSearchItem, +): Extract { + return { kind: "github_pr", item }; +} + +function issueAttachment(number: number): UserComposerAttachment { + return { + kind: "github_issue", + item: { + kind: "issue", + number, + title: `Issue ${number}`, + url: `https://example.com/issues/${number}`, + state: "open", + body: null, + labels: [], + }, + }; +} + +describe("syncPickerPrAttachment", () => { + it("selects a PR when no previous picker PR is set", () => { + const pr = makePrItem(202, "Refactor picker"); + const result = syncPickerPrAttachment({ + attachments: [], + previousPickerPrNumber: null, + item: { kind: "github-pr", item: pr }, + }); + expect(result.attachedPrNumber).toBe(202); + expect(result.attachments).toEqual([prAttachment(pr)]); + }); + + it("selects a branch without modifying attachments when no previous picker PR", () => { + const issue = issueAttachment(44); + const result = syncPickerPrAttachment({ + attachments: [issue], + previousPickerPrNumber: null, + item: { kind: "branch", name: "dev" }, + }); + expect(result.attachedPrNumber).toBeNull(); + expect(result.attachments).toEqual([issue]); + }); + + it("replaces the previous picker PR when a different PR is selected", () => { + const prA = makePrItem(202, "Refactor picker", "feature/picker"); + const prB = makePrItem(303, "Polish chip", "feature/chip"); + const result = syncPickerPrAttachment({ + attachments: [prAttachment(prA)], + previousPickerPrNumber: 202, + item: { kind: "github-pr", item: prB }, + }); + expect(result.attachedPrNumber).toBe(303); + expect(result.attachments).toEqual([prAttachment(prB)]); + }); + + it("removes the previous picker PR and adds no new attachment when a branch is selected", () => { + const pr = makePrItem(202, "Refactor picker"); + const issue = issueAttachment(44); + const result = syncPickerPrAttachment({ + attachments: [issue, prAttachment(pr)], + previousPickerPrNumber: 202, + item: { kind: "branch", name: "dev" }, + }); + expect(result.attachedPrNumber).toBeNull(); + expect(result.attachments).toEqual([issue]); + }); + + it("does not duplicate a PR that was already manually attached by the user", () => { + const pr = makePrItem(202, "Refactor picker"); + const result = syncPickerPrAttachment({ + attachments: [prAttachment(pr)], + previousPickerPrNumber: null, + item: { kind: "github-pr", item: pr }, + }); + expect(result.attachedPrNumber).toBeNull(); + expect(result.attachments).toHaveLength(1); + }); +}); + +describe("findCheckoutHintPrAttachment", () => { + it("returns the first attached PR that is not selected or dismissed", () => { + const first = prAttachment(makePrItem(101, "A")); + const second = prAttachment(makePrItem(202, "B")); + + expect( + findCheckoutHintPrAttachment({ + attachments: [issueAttachment(44), first, second], + selectedItem: null, + dismissedPrNumbers: new Set(), + }), + ).toBe(first); + }); + + it("skips the selected PR and offers the next attached PR", () => { + const selected = prAttachment(makePrItem(101, "A")); + const next = prAttachment(makePrItem(202, "B")); + + expect( + findCheckoutHintPrAttachment({ + attachments: [selected, next], + selectedItem: { kind: "github-pr", item: selected.item }, + dismissedPrNumbers: new Set(), + }), + ).toBe(next); + }); + + it("skips dismissed PRs and ignores issues", () => { + const dismissed = prAttachment(makePrItem(101, "A")); + const next = prAttachment(makePrItem(202, "B")); + + expect( + findCheckoutHintPrAttachment({ + attachments: [issueAttachment(44), dismissed, next], + selectedItem: null, + dismissedPrNumbers: new Set([101]), + }), + ).toBe(next); + }); + + it("returns null when only issues qualify", () => { + expect( + findCheckoutHintPrAttachment({ + attachments: [issueAttachment(44)], + selectedItem: null, + dismissedPrNumbers: new Set(), + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-state.ts b/packages/app/src/screens/new-workspace-picker-state.ts new file mode 100644 index 000000000..c3a25452e --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-state.ts @@ -0,0 +1,54 @@ +import type { UserComposerAttachment } from "@/attachments/types"; +import type { PickerItem } from "./new-workspace-picker-item"; + +// The picker "owns" at most one PR attachment at a time. When the user selects +// a different item the previously-owned PR is removed before the new one is added. +// User-added attachments for other PRs/issues are left untouched. +export function syncPickerPrAttachment(input: { + attachments: UserComposerAttachment[]; + previousPickerPrNumber: number | null; + item: PickerItem; +}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } { + let nextAttachments = input.attachments; + let attachedPrNumber: number | null = null; + + if (input.previousPickerPrNumber !== null) { + nextAttachments = nextAttachments.filter( + (attachment) => + attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber, + ); + } + + if (input.item.kind === "github-pr") { + const selectedPr = input.item.item; + const hasExistingPrAttachment = nextAttachments.some( + (attachment) => + attachment.kind === "github_pr" && attachment.item.number === selectedPr.number, + ); + if (!hasExistingPrAttachment) { + nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }]; + attachedPrNumber = selectedPr.number; + } + } + + return { attachments: nextAttachments, attachedPrNumber }; +} + +export function findCheckoutHintPrAttachment(input: { + attachments: ReadonlyArray; + selectedItem: PickerItem | null; + dismissedPrNumbers: ReadonlySet; +}): Extract | null { + const selectedPrNumber = + input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null; + + for (const attachment of input.attachments) { + if (attachment.kind !== "github_pr") continue; + const prNumber = attachment.item.number; + if (prNumber === selectedPrNumber) continue; + if (input.dismissedPrNumbers.has(prNumber)) continue; + return attachment; + } + + return null; +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx new file mode 100644 index 000000000..0cb8630eb --- /dev/null +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -0,0 +1,2587 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactElement, RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { Pressable, Text, View } from "react-native"; +import type { PressableStateCallbackType } from "react-native"; +import ReanimatedAnimated from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { createNameId } from "mnemonic-id"; +import { useQuery } from "@tanstack/react-query"; +import { + Check, + ChevronDown, + FileText, + Folder, + GitBranch, + GitPullRequest, + X, +} from "@/components/icons/material-icons"; +import { Composer } from "@/composer"; +import { Button } from "@/components/ui/button"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; +import { HostStatusDot } from "@/components/host-status-dot"; +import { HostPicker } from "@/components/hosts/host-picker"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { Combobox, ComboboxItem } from "@/components/ui/combobox"; +import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { ScreenHeader } from "@/components/headers/screen-header"; +import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { compactUp } from "@/styles/theme"; +import { useToast } from "@/contexts/toast-context"; +import { useAgentInputDraft } from "@/composer/draft/input-draft"; +import { resolveSpawnPersonalityId } from "@/composer/draft/workspace-tab-core"; +import { useGithubSearchQuery } from "@/git/use-github-search-query"; +import { + useHostRuntimeClient, + useHostRuntimeConnectionStatuses, + useHostRuntimeIsConnected, + useHosts, + type HostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; +import { useHostFeature, useHostFeatureMap } from "@/runtime/host-features"; +import type { HostProfile } from "@/types/host-connection"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { confirmDialogWithCheckbox } from "@/utils/confirm-dialog"; +import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, + type WorkspaceDescriptor, +} from "@/stores/session-store"; +import { + findWorkspaceById, + findWorkspaceForDirectory, + findWorkspaceForProject, +} from "./new-workspace-existing-workspace"; +import { useWorkspace } from "@/stores/session-store-hooks"; +import { generateDraftId } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; +import { + useWorkspaceDraftSubmissionStore, + type PendingWorkspaceDraftSetup, +} from "@/stores/workspace-draft-submission-store"; +import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useFormPreferences } from "@/hooks/use-form-preferences"; +import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; +import { generateMessageId } from "@/types/stream"; +import { toErrorMessage } from "@/utils/error-messages"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { + getHostProjectSourceDirectory, + hostProjectFromRoute, + hostProjectFromWorkspace, + useHostProjects, + type HostProjectListItem, +} from "@/projects/host-projects"; +import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; +import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import type { MessagePayload } from "@/composer/types"; +import type { AgentAttachment, GitHubSearchItem } from "@otto-code/protocol/messages"; +import type { CreateOttoWorktreeInput } from "@otto-code/client/internal/daemon-client"; +import type { AgentProvider } from "@otto-code/protocol/agent-types"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; +import { + isWorkspaceDirectoryOccupiedError, + runOccupiedDirectorySteer, + WORKSPACE_DIRECTORY_OCCUPIED_CODE, + WorkspaceDirectoryOccupiedClientError, +} from "./new-workspace-occupied-directory"; +import { resolveReadmeFileName, runViewDocumentation } from "./new-workspace-view-documentation"; +import { + getWorkspaceNamingAttachments, + remapDraftCwdToWorkspace, +} from "./new-workspace-fork-context"; +import { + pickerItemToCheckoutRequest, + type PickerCheckoutRequest, + type PickerItem, +} from "./new-workspace-picker-item"; +import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state"; +import { + resolveNewWorkspaceAutomaticServerId, + resolveNewWorkspaceInitialServerId, +} from "./new-workspace-initial-context"; +import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; + +function resolveCheckoutRequest( + selectedItem: PickerItem | null, + currentBranch: string | null, +): PickerCheckoutRequest | undefined { + const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem); + if (selectedCheckoutRequest) return selectedCheckoutRequest; + if (!currentBranch) return undefined; + return { + action: "branch-off", + refName: currentBranch, + }; +} + +function useIsNewWorkspaceDraftHandoffActive(input: { + draftId: string | undefined; + selectedServerId: string; +}): boolean { + const normalizedDraftId = input.draftId?.trim() ?? ""; + return useCreateFlowStore((state) => + isActiveCreateFlowForDraft({ + draftId: normalizedDraftId, + serverId: input.selectedServerId, + pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null, + }), + ); +} + +function resolveVisibleDraftContextScopeKeys(input: { + isDraftHandoffActive: boolean; + draftContextScopeKey: string; +}): readonly string[] { + if (input.isDraftHandoffActive || !input.draftContextScopeKey) { + return []; + } + return [input.draftContextScopeKey]; +} + +function isNewWorkspacePending(input: { + pendingAction: "chat" | "empty" | "docs" | null; + isDraftHandoffActive: boolean; +}): boolean { + return input.pendingAction !== null || input.isDraftHandoffActive; +} + +function buildFirstAgentContext(input: { + prompt: string; + attachments: AgentAttachment[]; +}): { prompt?: string; attachments?: AgentAttachment[] } | undefined { + const trimmedPrompt = input.prompt.trim(); + if (!trimmedPrompt && input.attachments.length === 0) { + return undefined; + } + + return { + ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), + attachments: input.attachments, + }; +} + +interface NewWorkspaceScreenProps { + serverId: string; + sourceDirectory?: string; + projectId?: string; + displayName?: string; + draftId?: string; +} + +interface PickerOptionData { + options: ComboboxOptionType[]; + itemById: Map; +} + +interface PickerSelection { + item: PickerItem; + attachedPrNumber: number | null; +} + +const BRANCH_OPTION_PREFIX = "branch:"; +const PR_OPTION_PREFIX = "github-pr:"; +const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; +// Height of a single picker-trigger badge. +const BADGE_HEIGHT = 28; + +function RefPickerBadgeContent({ + selectedItem, + triggerLabel, + iconColor, + iconSize, +}: { + selectedItem: PickerItem | null; + triggerLabel: string; + iconColor: string; + iconSize: number; +}) { + return ( + <> + + {selectedItem?.kind === "github-pr" ? ( + + ) : ( + + )} + + + {triggerLabel} + + + ); +} + +function RefPickerTrigger({ + pickerAnchorRef, + onPress, + disabled, + badgePressableStyle, + selectedItem, + triggerLabel, + accessibilityLabel, + tooltipLabel, + iconColor, + iconSize, +}: { + pickerAnchorRef: React.RefObject; + onPress: () => void; + disabled: boolean; + badgePressableStyle: React.ComponentProps["style"]; + selectedItem: PickerItem | null; + triggerLabel: string; + accessibilityLabel: string; + tooltipLabel: string; + iconColor: string; + iconSize: number; +}) { + return ( + + + + + + + + {tooltipLabel} + + + ); +} + +function ProjectPickerTrigger({ + pickerAnchorRef, + onPress, + disabled, + badgePressableStyle, + label, + projectKey, + iconDataUri, + iconColor, + iconSize, +}: { + pickerAnchorRef: React.RefObject; + onPress: () => void; + disabled: boolean; + badgePressableStyle: React.ComponentProps["style"]; + label: string; + projectKey: string | null; + iconDataUri: string | null; + iconColor: string; + iconSize: number; +}) { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; + return ( + + + + + {projectKey ? ( + + ) : ( + + )} + + + {label} + + + + + Choose project + + + ); +} + +function CheckoutHintBadge({ + label, + acceptLabel, + dismissLabel, + onAccept, + onDismiss, + iconColor, + iconSize, +}: { + label: string; + acceptLabel: string; + dismissLabel: string; + onAccept: () => void; + onDismiss: () => void; + iconColor: string; + iconSize: number; +}) { + return ( + + + {label} + + + + + + + + + ); +} + +function ViewDocumentationButton({ + readmeFileName, + onPress, + loading, + disabled, + label, + icon, +}: { + readmeFileName: string | null | undefined; + onPress: (readmeFileName: string) => void; + loading: boolean; + disabled: boolean; + label: string; + icon: ReactElement; +}) { + const handlePress = useCallback(() => { + if (readmeFileName) { + onPress(readmeFileName); + } + }, [onPress, readmeFileName]); + if (!readmeFileName) { + return null; + } + return ( + + ); +} + +function PickerOptionItem({ + testID, + label, + description, + selected, + active, + disabled, + onPress, + isBranch, + iconColor, + iconSize, +}: { + testID: string; + label: string; + description: string | undefined; + selected: boolean; + active: boolean; + disabled: boolean; + onPress: () => void; + isBranch: boolean; + iconColor: string; + iconSize: number; +}) { + const leadingSlot = useMemo( + () => ( + + {isBranch ? ( + + ) : ( + + )} + + ), + [isBranch, iconSize, iconColor], + ); + return ( + + ); +} + +function IsolationOptionItem({ + optionId, + label, + selected, + active, + disabled, + onPress, + iconColor, + iconSize, +}: { + optionId: string; + label: string; + selected: boolean; + active: boolean; + disabled: boolean; + onPress: () => void; + iconColor: string; + iconSize: number; +}) { + const leadingSlot = useMemo( + () => ( + + {optionId === "worktree" ? ( + + ) : ( + + )} + + ), + [optionId, iconSize, iconColor], + ); + return ( + + ); +} + +function ProjectOptionItem({ + testID, + projectKey, + iconDataUri, + label, + description, + selected, + active, + disabled, + onPress, +}: { + testID: string; + projectKey: string; + iconDataUri: string | null; + label: string; + description: string | undefined; + selected: boolean; + active: boolean; + disabled: boolean; + onPress: () => void; +}) { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; + const leadingSlot = useMemo( + () => ( + + + + ), + [iconDataUri, placeholderInitial, projectKey], + ); + + return ( + + ); +} + +function branchOptionId(name: string): string { + return `${BRANCH_OPTION_PREFIX}${name}`; +} + +function prOptionId(number: number): string { + return `${PR_OPTION_PREFIX}${number}`; +} + +function NewWorkspacePickerOption({ + option, + selected, + active, + onPress, + itemById, + isPending, +}: { + option: ComboboxOptionType; + selected: boolean; + active: boolean; + onPress: () => void; + itemById: Map; + isPending: boolean; +}) { + const { theme } = useUnistyles(); + const { t } = useTranslation(); + const item = itemById.get(option.id); + if (!item) return ; + + const isBranch = item.kind === "branch"; + const testID = isBranch + ? `new-workspace-ref-picker-branch-${item.name}` + : `new-workspace-ref-picker-pr-${item.item.number}`; + const description = + !isBranch && item.item.baseRefName + ? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName }) + : undefined; + + return ( + + ); +} + +function NewWorkspaceProjectPickerOption({ + option, + selected, + active, + onPress, + projectByOptionId, + projectIconDataByProjectKey, + selectedServerId, + isPending, + supportsWorkspaceMultiplicity, +}: { + option: ComboboxOptionType; + selected: boolean; + active: boolean; + onPress: () => void; + projectByOptionId: Map; + projectIconDataByProjectKey: Map; + selectedServerId: string; + isPending: boolean; + supportsWorkspaceMultiplicity: boolean; +}) { + const project = projectByOptionId.get(option.id); + if (!project) return ; + const sourceDirectory = + getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir; + + return ( + host.canCreateWorktree)) + } + onPress={onPress} + /> + ); +} + +function formatPrLabel(item: { number: number; title: string }): string { + return `#${item.number} ${item.title}`; +} + +function pickerItemLabel(item: PickerItem): string { + return item.kind === "branch" ? item.name : formatPrLabel(item.item); +} + +function pickerItemTriggerLabel(item: PickerItem): string { + return item.kind === "branch" ? item.name : formatPrLabel(item.item); +} + +function newWorkspaceHostOptionTestID(serverId: string): string { + return `new-workspace-host-picker-option-${serverId}`; +} + +function computePickerOptionData( + branchDetails: ReadonlyArray<{ name: string; committerDate: number }>, + prItems: ReadonlyArray, +): PickerOptionData { + const idMap = new Map(); + + interface TimedOption { + option: ComboboxOptionType; + timestamp: number; + } + const timedOptions: TimedOption[] = []; + + for (const branch of branchDetails) { + const id = branchOptionId(branch.name); + const option = { id, label: branch.name }; + idMap.set(id, { kind: "branch", name: branch.name }); + timedOptions.push({ option, timestamp: branch.committerDate }); + } + + for (const pr of prItems) { + if (!pr.headRefName) continue; + const id = prOptionId(pr.number); + const option = { id, label: formatPrLabel(pr) }; + idMap.set(id, { kind: "github-pr", item: pr }); + const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0; + const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000); + timedOptions.push({ option, timestamp }); + } + + timedOptions.sort((a, b) => b.timestamp - a.timestamp); + return { options: timedOptions.map((t) => t.option), itemById: idMap }; +} + +function IsolationPickerTrigger({ + pickerAnchorRef, + onPress, + disabled, + badgePressableStyle, + isolation, + label, + iconColor, + iconSize, +}: { + pickerAnchorRef: React.RefObject; + onPress: () => void; + disabled: boolean; + badgePressableStyle: React.ComponentProps["style"]; + isolation: "local" | "worktree"; + label: string; + iconColor: string; + iconSize: number; +}) { + return ( + + + {isolation === "worktree" ? ( + + ) : ( + + )} + + + {label} + + + ); +} + +// Wraps a single argument control in the mobile vertical stack. On desktop the +// controls are laid out in one horizontal row, so no per-control wrapper is used. +function FormRow({ children }: { children: React.ReactNode }) { + return {children}; +} + +interface WorkspaceIsolationState { + isolation: "local" | "worktree"; + setIsolation: (value: "local" | "worktree") => void; + effectiveIsolation: "local" | "worktree"; + canCreateWorktree: boolean; + showRefPicker: boolean; +} + +// Worktree isolation only makes sense for a git checkout. The effective isolation +// falls back to local whenever the selected directory isn't git so the flow +// never submits an impossible request. +function useWorkspaceIsolation(input: { + supportsMultiplicity: boolean; + selectedIsGit: boolean; +}): WorkspaceIsolationState { + const { supportsMultiplicity, selectedIsGit } = input; + // The last isolation choice is remembered alongside the other New Workspace + // form preferences (provider, model, mode). A manual in-screen pick overrides + // the remembered default until the screen remounts. + const { preferences, updatePreferences } = useFormPreferences(); + const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null); + const isolation = manualIsolation ?? preferences.isolation ?? "local"; + const canCreateWorktree = supportsMultiplicity && selectedIsGit; + const isWorktree = isolation === "worktree" && canCreateWorktree; + + const setIsolation = useCallback( + (value: "local" | "worktree") => { + setManualIsolation(value); + void updatePreferences({ isolation: value }); + }, + [updatePreferences], + ); + + return { + isolation, + setIsolation, + effectiveIsolation: isWorktree ? "worktree" : "local", + canCreateWorktree, + showRefPicker: !supportsMultiplicity || isWorktree, + }; +} + +function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string { + return isolation === "worktree" + ? t("newWorkspace.isolation.worktree") + : t("newWorkspace.isolation.local"); +} + +function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { + if (input.isCompact) { + return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; + } + return [styles.content, styles.contentCentered]; +} + +function buildNewWorkspaceDraftKey(input: { + selectedServerId: string; + selectedSourceDirectory: string | null; + draftId?: string; +}): string { + const explicitDraftId = input.draftId?.trim(); + if (explicitDraftId) { + return `new-workspace:draft:${explicitDraftId}`; + } + return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`; +} + +function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null { + if (!selection) return null; + return selection.item; +} + +function normalizeBranchDetails( + data: + | { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } + | undefined, +): Array<{ name: string; committerDate: number }> { + const details = data?.branchDetails; + if (details && details.length > 0) return details; + const names = data?.branches ?? []; + return names.map((name) => ({ name, committerDate: 0 })); +} + +interface SubmitDraftInput { + serverId: string; + draftKey: string; + draftId?: string; + initialSetup?: WorkspaceDraftTabSetup; + workspaceId: string; + workspaceDirectory: string; + text: string; + attachments: ComposerAttachment[]; + provider: AgentProvider; + composerState: NewWorkspaceComposerState; +} + +type NewWorkspaceComposerState = NonNullable< + ReturnType["composerState"] +>; + +interface WorkspaceDraftSubmissionConfig { + cwd: string; + provider: AgentProvider; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record | undefined; + target: WorkspaceTabTarget; +} + +async function createAndMergeWorkspace(input: { + client: NonNullable>; + createInput: Parameters< + NonNullable>["createOttoWorktree"] + >[0]; + mergeWorkspaces: ( + serverId: string, + workspaces: ReturnType[], + ) => void; + serverId: string; + createFailedMessage: string; +}): Promise> { + const payload = await input.client.createOttoWorktree(input.createInput); + if (payload.error || !payload.workspace) { + throw new Error(payload.error ?? input.createFailedMessage); + } + const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); + const workspaceForInitialMerge = input.createInput.firstAgentContext + ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } + : normalizedWorkspace; + input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); + return normalizedWorkspace; +} + +async function createMultiplicityWorkspace(input: { + client: NonNullable>; + isolation: "local" | "worktree"; + project: HostProjectListItem; + sourceDirectory: string; + selectedItem: PickerItem | null; + currentBranch: string | null; + withInitialAgent: boolean; + prompt: string; + attachments: AgentAttachment[]; + mergeWorkspaces: ( + serverId: string, + workspaces: ReturnType[], + ) => void; + serverId: string; + createFailedMessage: string; +}): Promise> { + const isWorktree = input.isolation === "worktree"; + const checkoutRequest = isWorktree + ? resolveCheckoutRequest(input.selectedItem, input.currentBranch) + : undefined; + const firstAgentContext = buildFirstAgentContext({ + prompt: input.prompt, + attachments: input.attachments, + }); + const payload = await input.client.createWorkspace({ + source: isWorktree + ? { + kind: "worktree", + cwd: input.sourceDirectory, + projectId: input.project.projectKey, + worktreeSlug: createNameId(), + ...checkoutRequest, + } + : { + kind: "directory", + path: input.sourceDirectory, + projectId: input.project.projectKey, + }, + ...(firstAgentContext ? { firstAgentContext } : {}), + }); + if (payload.error || !payload.workspace) { + // One directory = one live workspace. Keep this refusal distinguishable so + // the submit handler can offer "open it" / "make a worktree" instead of a + // dead-end toast. + if (payload.errorCode === WORKSPACE_DIRECTORY_OCCUPIED_CODE && payload.error) { + throw new WorkspaceDirectoryOccupiedClientError(payload.error, input.sourceDirectory); + } + throw new Error(payload.error ?? input.createFailedMessage); + } + const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); + const workspaceForInitialMerge = input.withInitialAgent + ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } + : normalizedWorkspace; + input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); + return normalizedWorkspace; +} + +interface CreateChatAgentInput { + payload: MessagePayload; + composerState: ReturnType["composerState"]; + forkDraftSetup?: PendingWorkspaceDraftSetup | null; + ensureWorkspace: (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + withInitialAgent: boolean; + }) => Promise>; + serverId: string; + draftKey: string; + draftId?: string; + labels: { + composerStateRequired: string; + selectModel: string; + }; +} + +function buildWorkspaceDraftSetupFromComposer(input: { + cwd: string; + provider: AgentProvider; + composerState: NewWorkspaceComposerState; +}): WorkspaceDraftTabSetup { + return { + provider: input.provider, + cwd: input.cwd, + modeId: input.composerState.selectedMode || null, + model: input.composerState.effectiveModelId || null, + thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, + featureValues: input.composerState.featureValues ?? {}, + // Carry the picked identity, not just its provider/model — the draft tab's + // initialValues outrank device memory, so a dropped personality here can't + // be recovered downstream. + personality: resolveSpawnPersonalityId(input.composerState.agentControls.personality), + }; +} + +function buildWorkspaceDraftSetupForCreatedWorkspace(input: { + forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined; + workspaceDirectory: string; + provider: AgentProvider; + composerState: NewWorkspaceComposerState; +}): WorkspaceDraftTabSetup | undefined { + if (!input.forkDraftSetup) { + return undefined; + } + return buildWorkspaceDraftSetupFromComposer({ + cwd: remapDraftCwdToWorkspace({ + cwd: input.forkDraftSetup.setup.cwd, + sourceDirectory: input.forkDraftSetup.sourceDirectory, + workspaceDirectory: input.workspaceDirectory, + }), + provider: input.provider, + composerState: input.composerState, + }); +} + +function buildComposerInitialValues(input: { + workingDir: string | undefined; + initialSetup?: WorkspaceDraftTabSetup | null; +}): CreateAgentInitialValues | undefined { + if (input.initialSetup) { + return { + workingDir: input.workingDir ?? input.initialSetup.cwd, + provider: input.initialSetup.provider, + modeId: input.initialSetup.modeId, + model: input.initialSetup.model, + thinkingOptionId: input.initialSetup.thinkingOptionId, + }; + } + if (input.workingDir) { + return { workingDir: input.workingDir }; + } + return undefined; +} + +async function runCreateChatAgent(input: CreateChatAgentInput): Promise { + const { payload, composerState, ensureWorkspace, serverId, draftKey } = input; + const { text, attachments, cwd } = payload; + if (!composerState) { + throw new Error(input.labels.composerStateRequired); + } + const provider = composerState.selectedProvider; + if (!provider) { + throw new Error(input.labels.selectModel); + } + const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments); + const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments); + const ensuredWorkspace = await ensureWorkspace({ + cwd, + prompt: text, + attachments: workspaceNamingAttachments, + withInitialAgent: true, + }); + const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ + forkDraftSetup: input.forkDraftSetup, + workspaceDirectory: ensuredWorkspace.workspaceDirectory, + provider, + composerState, + }); + submitWorkspaceDraft({ + serverId, + draftKey, + draftId: input.draftId, + initialSetup, + workspaceId: ensuredWorkspace.id, + workspaceDirectory: ensuredWorkspace.workspaceDirectory, + text, + attachments, + provider, + composerState, + }); +} + +function buildComposerConfig(input: { + serverId: string; + isConnected: boolean; + workspaceDirectory: string | null; + sourceDirectory: string | null; + initialSetup?: WorkspaceDraftTabSetup | null; +}): Parameters[0]["composer"] { + const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; + const workingDir = workspaceDirectory || sourceDirectory || undefined; + return { + initialServerId: serverId || null, + initialValues: buildComposerInitialValues({ workingDir, initialSetup }), + initialFeatureValues: initialSetup?.featureValues, + isVisible: true, + onlineServerIds: isConnected && serverId ? [serverId] : [], + lockedWorkingDir: workingDir, + initialPersonalityId: initialSetup?.personality ?? null, + }; +} + +function collectAttachedPrNumbers(attachments: ReadonlyArray): Set { + const numbers = new Set(); + for (const attachment of attachments) { + if (attachment.kind === "github_pr") { + numbers.add(attachment.item.number); + } + } + return numbers; +} + +function pruneDismissedCheckoutHintPrNumbers( + dismissed: ReadonlySet, + attached: ReadonlySet, +): ReadonlySet { + let changed = false; + const next = new Set(); + for (const prNumber of dismissed) { + if (attached.has(prNumber)) { + next.add(prNumber); + } else { + changed = true; + } + } + return changed ? next : dismissed; +} + +function useCheckoutHintDismissals(attachments: ReadonlyArray) { + const [dismissedPrNumbers, setDismissedPrNumbers] = useState>( + () => new Set(), + ); + const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]); + + useEffect(() => { + setDismissedPrNumbers((current) => + pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers), + ); + }, [attachedPrNumbers]); + + return [dismissedPrNumbers, setDismissedPrNumbers] as const; +} + +function usePendingWorkspaceDraftSetup( + draftId: string | undefined, +): PendingWorkspaceDraftSetup | null { + const normalizedDraftId = draftId?.trim() ?? ""; + return useWorkspaceDraftSubmissionStore((state) => { + if (!normalizedDraftId) { + return null; + } + return state.setupByDraftId[normalizedDraftId] ?? null; + }); +} + +function resolveWorkspaceDraftSubmissionConfig(input: { + draftId: string; + workspaceDirectory: string; + provider: AgentProvider; + composerState: NewWorkspaceComposerState; + initialSetup?: WorkspaceDraftTabSetup; +}): WorkspaceDraftSubmissionConfig { + const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input; + if (initialSetup) { + return { + cwd: initialSetup.cwd, + provider: initialSetup.provider, + modeId: initialSetup.modeId, + model: initialSetup.model, + thinkingOptionId: initialSetup.thinkingOptionId, + featureValues: initialSetup.featureValues, + target: { kind: "draft", draftId, setup: initialSetup }, + }; + } + return { + cwd: workspaceDirectory, + provider, + modeId: composerState.selectedMode || null, + model: composerState.effectiveModelId || null, + thinkingOptionId: composerState.effectiveThinkingOptionId || null, + featureValues: composerState.featureValues, + target: { kind: "draft", draftId }, + }; +} + +function submitWorkspaceDraft(input: SubmitDraftInput): void { + const { + serverId, + draftKey, + draftId: draftIdInput, + workspaceId, + workspaceDirectory, + text, + attachments, + provider, + composerState, + initialSetup, + } = input; + const draftId = draftIdInput?.trim() || generateDraftId(); + const clientMessageId = generateMessageId(); + const timestamp = Date.now(); + const wirePayload = splitComposerAttachmentsForSubmit(attachments); + // The picker's selected id is a UI-only sentinel when the "Team's " + // slot is active; only the resolved member id may be frozen onto the pending + // submission, since the destination tab spawns from it verbatim. + const spawnPersonalityId = resolveSpawnPersonalityId(composerState.agentControls.personality); + const submission = resolveWorkspaceDraftSubmissionConfig({ + draftId, + workspaceDirectory, + provider, + composerState, + initialSetup, + }); + useCreateFlowStore.getState().setPending({ + serverId, + draftId, + workspaceId, + agentId: null, + clientMessageId, + text: text.trim(), + timestamp, + ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), + ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), + }); + useWorkspaceDraftSubmissionStore.getState().setPending({ + serverId, + workspaceId, + draftId, + text: text.trim(), + attachments, + cwd: submission.cwd, + provider: submission.provider, + clientMessageId, + timestamp, + ...(submission.modeId ? { modeId: submission.modeId } : {}), + ...(submission.model ? { model: submission.model } : {}), + ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), + ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), + ...(spawnPersonalityId ? { personality: spawnPersonalityId } : {}), + allowEmptyText: true, + }); + navigateToPreparedWorkspaceTab({ + serverId, + workspaceId, + target: submission.target, + }); + useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); +} + +function useNewWorkspaceHostSelector(input: { + initialServerId: string; + allServerIds: string[]; + projects: HostProjectListItem[]; + lastActiveProject: HostProjectListItem | null; + hostConnectionStatusByServerId: ReadonlyMap; + workspaceMultiplicityByServerId: ReadonlyMap; +}) { + const routeServerId = input.initialServerId.trim(); + const defaultServerId = useMemo( + () => + resolveNewWorkspaceInitialServerId({ + allServerIds: input.allServerIds, + routeServerId: input.initialServerId, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + hostConnectionStatusByServerId: input.hostConnectionStatusByServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + }), + [ + input.allServerIds, + input.hostConnectionStatusByServerId, + input.initialServerId, + input.lastActiveProject, + input.projects, + input.workspaceMultiplicityByServerId, + ], + ); + const [automaticSelection, setAutomaticSelection] = useState(() => ({ + routeServerId, + serverId: defaultServerId, + })); + const [manualSelection, setManualSelection] = useState<{ + routeServerId: string; + serverId: string; + } | null>(null); + const [hostPickerOpen, setHostPickerOpen] = useState(false); + + useEffect(() => { + setAutomaticSelection((current) => { + const nextServerId = + current.routeServerId === routeServerId + ? resolveNewWorkspaceAutomaticServerId({ + allServerIds: input.allServerIds, + routeServerId: input.initialServerId, + lastActiveProject: input.lastActiveProject, + projects: input.projects, + hostConnectionStatusByServerId: input.hostConnectionStatusByServerId, + workspaceMultiplicityByServerId: input.workspaceMultiplicityByServerId, + currentServerId: current.serverId, + nextServerId: defaultServerId, + }) + : defaultServerId; + + if (current.routeServerId === routeServerId && current.serverId === nextServerId) { + return current; + } + + return { routeServerId, serverId: nextServerId }; + }); + }, [ + defaultServerId, + input.allServerIds, + input.hostConnectionStatusByServerId, + input.initialServerId, + input.lastActiveProject, + input.projects, + input.workspaceMultiplicityByServerId, + routeServerId, + ]); + + const automaticServerId = + automaticSelection.routeServerId === routeServerId && + input.allServerIds.includes(automaticSelection.serverId) + ? automaticSelection.serverId + : defaultServerId; + const selectedServerId = + manualSelection?.routeServerId === routeServerId && + input.allServerIds.includes(manualSelection.serverId) + ? manualSelection.serverId + : automaticServerId; + + const handleSelectHost = useCallback( + (id: string) => { + setManualSelection({ routeServerId, serverId: id }); + setHostPickerOpen(false); + }, + [routeServerId], + ); + + const handleHostPickerOpenChange = useCallback((open: boolean) => { + setHostPickerOpen(open); + }, []); + + const openHostPicker = useCallback(() => { + setHostPickerOpen(true); + }, []); + + return { + selectedServerId, + hostPickerOpen, + handleSelectHost, + handleHostPickerOpenChange, + openHostPicker, + }; +} + +interface NewWorkspaceInitialContextState { + allHosts: HostProfile[]; + selectedServerId: string; + hostPickerOpen: boolean; + handleSelectHost: (id: string) => void; + handleHostPickerOpenChange: (open: boolean) => void; + openHostPicker: () => void; + projects: HostProjectListItem[]; + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; +} + +function useNewWorkspaceInitialContext({ + serverId, + sourceDirectory: sourceDirectoryProp, + projectId, + displayName: displayNameProp, +}: NewWorkspaceScreenProps): NewWorkspaceInitialContextState { + const allHosts = useHosts(); + const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); + const projects = useHostProjects(allServerIds); + const routeDisplayName = displayNameProp?.trim() ?? ""; + const routeProject = useMemo( + () => + hostProjectFromRoute({ + serverId, + projectId, + displayName: routeDisplayName, + sourceDirectory: sourceDirectoryProp, + }), + [projectId, routeDisplayName, serverId, sourceDirectoryProp], + ); + const lastWorkspaceSelection = useLastWorkspaceSelection(); + const lastWorkspaceServerId = useMemo( + () => + lastWorkspaceSelection && allServerIds.includes(lastWorkspaceSelection.serverId) + ? lastWorkspaceSelection.serverId + : null, + [allServerIds, lastWorkspaceSelection], + ); + const lastWorkspaceId = lastWorkspaceServerId ? lastWorkspaceSelection!.workspaceId : null; + const lastWorkspace = useWorkspace(lastWorkspaceServerId, lastWorkspaceId); + const lastActiveProject = useMemo( + () => + lastWorkspaceServerId + ? hostProjectFromWorkspace({ serverId: lastWorkspaceServerId, workspace: lastWorkspace }) + : null, + [lastWorkspace, lastWorkspaceServerId], + ); + const hostConnectionStatusByServerId = useHostRuntimeConnectionStatuses(allServerIds); + const workspaceMultiplicityByServerId = useHostFeatureMap(allServerIds, "workspaceMultiplicity"); + const { + selectedServerId, + hostPickerOpen, + handleSelectHost, + handleHostPickerOpenChange, + openHostPicker, + } = useNewWorkspaceHostSelector({ + initialServerId: serverId, + allServerIds, + projects, + lastActiveProject, + hostConnectionStatusByServerId, + workspaceMultiplicityByServerId, + }); + + return { + allHosts, + selectedServerId, + hostPickerOpen, + handleSelectHost, + handleHostPickerOpenChange, + openHostPicker, + projects, + routeProject, + lastActiveProject, + }; +} + +type RefPickerRenderOption = NonNullable; + +interface FormPickerControl { + anchorRef: RefObject; + open: () => void; + openState: boolean; + onOpenChange: (open: boolean) => void; +} + +interface NewWorkspaceFormStackInput { + isCompact: boolean; + isPending: boolean; + project: FormPickerControl & { + options: ComboboxOptionType[]; + triggerLabel: string; + selectedProject: HostProjectListItem | null; + iconDataByProjectKey: Map; + selectedOptionId: string; + onSelect: (id: string) => void; + renderOption: RefPickerRenderOption; + }; + host: FormPickerControl & { + allHosts: HostProfile[]; + selectedServerId: string; + onSelect: (id: string) => void; + }; + isolation: FormPickerControl & { + effectiveIsolation: "local" | "worktree"; + options: ComboboxOptionType[]; + onSelect: (id: string) => void; + renderOption: RefPickerRenderOption; + canCreateWorktree: boolean; + }; + base: FormPickerControl & { + selectedSourceDirectory: string | null; + selectedItem: PickerItem | null; + triggerLabel: string; + options: ComboboxOptionType[]; + selectedOptionId: string; + onSelect: (id: string) => void; + setSearchQuery: (query: string) => void; + emptyText: string; + renderOption: RefPickerRenderOption; + showRefPicker: boolean; + }; +} + +function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactElement { + const { theme } = useUnistyles(); + const { t } = useTranslation(); + const { isCompact, isPending, project, host, isolation, base } = input; + + const selectedHostLabel = + host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; + const showHostControl = host.allHosts.length > 1; + const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); + + const badgePressableStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.badge, + Boolean(hovered) && !isPending && styles.badgeHovered, + pressed && !isPending && styles.badgePressed, + isPending && styles.badgeDisabled, + ], + [isPending], + ); + + const projectControl = ( + + + + + ); + + const hostControl = showHostControl ? ( + + + + + + {selectedHostLabel} + + + + + + ) : null; + + const isolationControl = isolation.canCreateWorktree ? ( + + + + + ) : null; + + const baseControl = base.showRefPicker ? ( + + + + + ) : null; + + return isCompact ? ( + + {projectControl} + {hostControl ? {hostControl} : null} + {isolationControl ? {isolationControl} : null} + {baseControl ? {baseControl} : null} + + ) : ( + + {projectControl} + {hostControl} + {isolationControl} + {baseControl} + + ); +} + +// The live workspace already backed by `directory`, if any. Same resolved-path +// equality the daemon uses to reject a second workspace on one checkout +// (`findOccupyingWorkspaceForCwd`), so "reuse" here matches "rejected" there. +function workspacesForServer(serverId: string): Iterable | undefined { + return useSessionStore.getState().sessions[serverId]?.workspaces?.values(); +} + +function findWorkspaceIdForDirectory(serverId: string, directory: string): string | null { + return ( + findWorkspaceForDirectory({ workspaces: workspacesForServer(serverId), directory })?.id ?? null + ); +} + +/** + * Widened on purpose relative to `findWorkspaceIdForDirectory`: opening a file + * only needs *a* workspace for the project, not the one that owns the root. See + * `findWorkspaceForProject`. + */ +function findWorkspaceIdForProject(serverId: string, sourceDirectory: string): string | null { + return ( + findWorkspaceForProject({ workspaces: workspacesForServer(serverId), sourceDirectory })?.id ?? + null + ); +} + +export function NewWorkspaceScreen({ + serverId, + sourceDirectory: sourceDirectoryProp, + projectId, + displayName: displayNameProp, + draftId, +}: NewWorkspaceScreenProps) { + const { theme } = useUnistyles(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const isCompact = useIsCompactFormFactor(); + const toast = useToast(); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const { + allHosts, + selectedServerId, + hostPickerOpen, + handleSelectHost, + handleHostPickerOpenChange, + openHostPicker, + projects, + routeProject, + lastActiveProject, + } = useNewWorkspaceInitialContext({ + serverId, + sourceDirectory: sourceDirectoryProp, + projectId, + displayName: displayNameProp, + }); + // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 + const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); + const [errorMessage, setErrorMessage] = useState(null); + const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); + const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "docs" | null>(null); + const [manualPickerSelection, setManualPickerSelection] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [projectPickerOpen, setProjectPickerOpen] = useState(false); + const [isolationPickerOpen, setIsolationPickerOpen] = useState(false); + const [pickerSearchQuery, setPickerSearchQuery] = useState(""); + const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); + const pickerAnchorRef = useRef(null); + const projectPickerAnchorRef = useRef(null); + const isolationPickerAnchorRef = useRef(null); + const hostPickerAnchorRef = useRef(null); + const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); + + useEffect(() => { + const trimmed = pickerSearchQuery.trim(); + const timer = setTimeout(() => setDebouncedPickerSearchQuery(trimmed), 180); + return () => clearTimeout(timer); + }, [pickerSearchQuery]); + + const workspace = createdWorkspace; + const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); + const client = useHostRuntimeClient(selectedServerId); + const isConnected = useHostRuntimeIsConnected(selectedServerId); + const { + selectedProject, + selectedSourceDirectory, + projectPickerOptions, + projectByOptionId, + selectedProjectOptionId, + projectTriggerLabel, + handleSelectProjectOption: selectProjectOption, + } = useNewWorkspaceProjectPicker({ + selectedServerId, + projects, + routeProject, + lastActiveProject, + allowAllProjects: supportsWorkspaceMultiplicity, + }); + + const projectIconTargets = useMemo( + () => + projects.flatMap((project) => { + const iconWorkingDir = getHostProjectSourceDirectory(project, selectedServerId)?.trim(); + if (!iconWorkingDir) { + return []; + } + return [{ projectKey: project.projectKey, serverId: selectedServerId, iconWorkingDir }]; + }), + [projects, selectedServerId], + ); + + const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ + projects: projectIconTargets, + }); + const draftKey = buildNewWorkspaceDraftKey({ + selectedServerId, + selectedSourceDirectory, + draftId, + }); + const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); + const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); + const visibleDraftContextScopeKeys = useMemo( + () => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }), + [draftContextScopeKey, isDraftHandoffActive], + ); + const chatDraft = useAgentInputDraft({ + draftKey, + composer: buildComposerConfig({ + serverId: selectedServerId, + isConnected, + workspaceDirectory: workspace?.workspaceDirectory ?? null, + sourceDirectory: selectedSourceDirectory, + initialSetup: forkDraftSetup?.setup, + }), + }); + const composerState = chatDraft.composerState; + const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] = + useCheckoutHintDismissals(chatDraft.attachments); + + const selectedItem = getSelectedPickerItem(manualPickerSelection); + + const withConnectedClient = useCallback(() => { + if (!client || !isConnected) { + throw new Error(t("newWorkspace.errors.hostDisconnected")); + } + return client; + }, [client, isConnected, t]); + + const clientReady = isConnected && Boolean(client); + const hasSelectedSourceDirectory = selectedSourceDirectory !== null; + const clientAndDirectoryReady = clientReady && hasSelectedSourceDirectory; + const pickerQueryEnabled = pickerOpen && clientAndDirectoryReady; + + const checkoutStatusQuery = useQuery({ + queryKey: ["checkout-status", selectedServerId, selectedSourceDirectory], + queryFn: async () => { + if (!selectedSourceDirectory) { + throw new Error("Choose a project"); + } + const connectedClient = withConnectedClient(); + return connectedClient.getCheckoutStatus(selectedSourceDirectory); + }, + enabled: clientAndDirectoryReady, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + + const readmeQuery = useQuery({ + queryKey: ["new-workspace-readme", selectedServerId, selectedSourceDirectory], + queryFn: () => + resolveReadmeFileName({ + sourceDirectory: selectedSourceDirectory, + getClient: withConnectedClient, + }), + enabled: clientAndDirectoryReady, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + const readmeFileName = readmeQuery.data; + + const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; + const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = + useWorkspaceIsolation({ + supportsMultiplicity: supportsWorkspaceMultiplicity, + selectedIsGit: checkoutStatusQuery.data?.isGit === true, + }); + + const branchSuggestionsQuery = useQuery({ + queryKey: [ + "branch-suggestions", + selectedServerId, + selectedSourceDirectory, + debouncedPickerSearchQuery, + ], + queryFn: async () => { + if (!selectedSourceDirectory) { + throw new Error("Choose a project"); + } + const connectedClient = withConnectedClient(); + return connectedClient.getBranchSuggestions({ + cwd: selectedSourceDirectory, + query: debouncedPickerSearchQuery || undefined, + limit: 20, + }); + }, + enabled: pickerQueryEnabled, + staleTime: 15_000, + }); + + const githubPrSearchQuery = useGithubSearchQuery({ + client, + serverId: selectedServerId, + cwd: selectedSourceDirectory ?? "", + query: debouncedPickerSearchQuery, + kinds: ["github-pr"], + enabled: pickerQueryEnabled, + }); + + const branchDetails = useMemo( + () => normalizeBranchDetails(branchSuggestionsQuery.data), + [branchSuggestionsQuery.data], + ); + const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false; + const prItems: GitHubSearchItem[] = useMemo(() => { + if (!githubFeaturesEnabled) return []; + return githubPrSearchQuery.data?.items ?? []; + }, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]); + + const { options, itemById }: PickerOptionData = useMemo( + () => computePickerOptionData(branchDetails, prItems), + [branchDetails, prItems], + ); + const triggerLabel = useMemo(() => { + if (selectedItem) return pickerItemTriggerLabel(selectedItem); + return currentBranch ?? "main"; + }, [currentBranch, selectedItem]); + + const selectedOptionId = useMemo(() => { + if (!selectedItem) return ""; + return selectedItem.kind === "branch" + ? branchOptionId(selectedItem.name) + : prOptionId(selectedItem.item.number); + }, [selectedItem]); + const selectPickerItem = useCallback( + (item: PickerItem) => { + const next = syncPickerPrAttachment({ + attachments: chatDraft.attachments, + previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null, + item, + }); + + setManualPickerSelection({ + item, + attachedPrNumber: next.attachedPrNumber, + }); + if (next.attachments !== chatDraft.attachments) { + chatDraft.setAttachments(next.attachments); + } + setPickerOpen(false); + }, + [chatDraft, manualPickerSelection?.attachedPrNumber], + ); + + const handleSelectOption = useCallback( + (id: string) => { + const item = itemById.get(id); + if (!item) return; + selectPickerItem(item); + }, + [itemById, selectPickerItem], + ); + + const handleSelectProjectOption = useCallback( + (id: string) => { + // selectProjectOption enforces selectability (worktree-only when + // multiplicity is off, any project when it's on); don't re-gate here on + // canCreateWorktree or non-git projects become unselectable. + selectProjectOption(id); + setProjectPickerOpen(false); + setManualPickerSelection(null); + }, + [selectProjectOption], + ); + + const checkoutHintPrAttachment = useMemo( + () => + findCheckoutHintPrAttachment({ + attachments: chatDraft.attachments, + selectedItem, + dismissedPrNumbers: dismissedCheckoutHintPrNumbers, + }), + [chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem], + ); + + const acceptCheckoutHint = useCallback(() => { + if (!checkoutHintPrAttachment) return; + selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item }); + }, [checkoutHintPrAttachment, selectPickerItem]); + + const dismissCheckoutHint = useCallback(() => { + if (!checkoutHintPrAttachment) return; + const prNumber = checkoutHintPrAttachment.item.number; + setDismissedCheckoutHintPrNumbers((current) => { + if (current.has(prNumber)) return current; + const next = new Set(current); + next.add(prNumber); + return next; + }); + }, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]); + + const openPicker = useCallback(() => { + setPickerOpen(true); + }, []); + + const openProjectPicker = useCallback(() => { + setProjectPickerOpen(true); + }, []); + + const openIsolationPicker = useCallback(() => { + setIsolationPickerOpen(true); + }, []); + + const handleIsolationPickerOpenChange = useCallback((nextOpen: boolean) => { + setIsolationPickerOpen(nextOpen); + }, []); + + // "New worktree" is omitted entirely (not disabled) when the project isn't a + // git checkout, since worktree isolation is impossible there. + const isolationOptions = useMemo(() => { + const localOption = { id: "local", label: isolationLabel(t, "local") }; + if (!canCreateWorktree) return [localOption]; + return [localOption, { id: "worktree", label: isolationLabel(t, "worktree") }]; + }, [canCreateWorktree, t]); + + const handleSelectIsolationOption = useCallback( + (id: string) => { + setIsolation(id === "worktree" ? "worktree" : "local"); + setIsolationPickerOpen(false); + }, + [setIsolation], + ); + + const renderIsolationOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOptionType; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + return ( + + ); + }, + [isPending, theme.colors.foregroundMuted, theme.iconSize.sm], + ); + + const handleClearDraft = useCallback(() => { + // No-op: screen navigates away on success, text should stay for retry on error + }, []); + + const handlePickerOpenChange = useCallback((nextOpen: boolean) => { + setPickerOpen(nextOpen); + if (!nextOpen) { + setPickerSearchQuery(""); + } + }, []); + + const handleProjectPickerOpenChange = useCallback((nextOpen: boolean) => { + setProjectPickerOpen(nextOpen); + }, []); + + const buildCreateWorktreeInput = useCallback( + (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + }): CreateOttoWorktreeInput => { + if (!selectedProject) { + throw new Error("Choose a project"); + } + if (!selectedSourceDirectory) { + throw new Error("Choose a host for this project"); + } + const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch); + const firstAgentContext = buildFirstAgentContext(input); + + return { + cwd: selectedSourceDirectory, + projectId: selectedProject.projectKey, + worktreeSlug: createNameId(), + ...(firstAgentContext ? { firstAgentContext } : {}), + ...checkoutRequest, + }; + }, + [currentBranch, selectedItem, selectedProject, selectedSourceDirectory], + ); + + const ensureWorkspace = useCallback( + async (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + withInitialAgent: boolean; + // Set by the occupied-directory steer to retry as a worktree without the + // user having to go back and flip the isolation control themselves. + isolationOverride?: "local" | "worktree"; + }) => { + if (createdWorkspace) { + return createdWorkspace; + } + if (!selectedProject) { + throw new Error("Choose a project"); + } + if (!selectedSourceDirectory) { + throw new Error("Choose a host for this project"); + } + const normalizedWorkspace = supportsWorkspaceMultiplicity + ? await createMultiplicityWorkspace({ + client: withConnectedClient(), + isolation: input.isolationOverride ?? effectiveIsolation, + project: selectedProject, + sourceDirectory: selectedSourceDirectory, + selectedItem, + currentBranch, + withInitialAgent: input.withInitialAgent, + prompt: input.prompt, + attachments: input.attachments, + mergeWorkspaces, + serverId: selectedServerId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }) + : await createAndMergeWorkspace({ + client: withConnectedClient(), + createInput: buildCreateWorktreeInput(input), + mergeWorkspaces, + serverId: selectedServerId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }); + setCreatedWorkspace(normalizedWorkspace); + return normalizedWorkspace; + }, + [ + buildCreateWorktreeInput, + createdWorkspace, + currentBranch, + effectiveIsolation, + mergeWorkspaces, + selectedItem, + selectedProject, + selectedServerId, + selectedSourceDirectory, + supportsWorkspaceMultiplicity, + t, + withConnectedClient, + ], + ); + + // The submission path itself, parameterised so the occupied-directory steer can + // replay the user's exact submission down either branch of its dialog: as a + // worktree, or into the workspace that is already there. + const runSubmitNewWorkspace = useCallback( + async ( + payload: MessagePayload, + submitOptions?: { + isolationOverride?: "local" | "worktree"; + // Short-circuits creation. Everything downstream of `ensureWorkspace` + // only reads the descriptor's id and directory, and the draft tab's + // auto-submit does not care whether the workspace is a second old or a + // week old, so handing back an existing descriptor is all it takes to + // start the chat there. + existingWorkspace?: WorkspaceDescriptor; + }, + ) => { + const { isolationOverride, existingWorkspace } = submitOptions ?? {}; + const ensureWorkspaceForSubmit: typeof ensureWorkspace = existingWorkspace + ? async () => existingWorkspace + : (input) => ensureWorkspace(isolationOverride ? { ...input, isolationOverride } : input); + setErrorMessage(null); + await composerState?.persistFormPreferences(); + if (isEmptyWorkspaceSubmission(payload)) { + setPendingAction("empty"); + await runCreateEmptyWorkspace({ + payload, + ensureWorkspace: ensureWorkspaceForSubmit, + serverId: selectedServerId, + navigate: (targetServerId, workspaceId) => + navigateToWorkspace(targetServerId, workspaceId), + }); + return; + } + + setPendingAction("chat"); + await runCreateChatAgent({ + payload, + composerState, + forkDraftSetup, + ensureWorkspace: ensureWorkspaceForSubmit, + serverId: selectedServerId, + draftKey, + draftId, + labels: { + composerStateRequired: t("newWorkspace.errors.composerStateRequired"), + selectModel: t("newWorkspace.errors.selectModel"), + }, + }); + }, + [composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t], + ); + + const handleSubmitNewWorkspace = useCallback( + async (payload: MessagePayload) => { + try { + await runSubmitNewWorkspace(payload); + } catch (error) { + setPendingAction(null); + // The directory already has a live workspace. Don't dead-end on a toast: + // offer to open that workspace, or to make the worktree that actually + // gives an independent branch. + if (isWorkspaceDirectoryOccupiedError(error)) { + setErrorMessage(null); + await runOccupiedDirectorySteer({ + error, + labels: { + title: t("newWorkspace.occupiedDirectory.title"), + openExisting: t("newWorkspace.occupiedDirectory.openExisting"), + createWorktree: t("newWorkspace.occupiedDirectory.createWorktree"), + }, + findExistingWorkspaceId: (directory) => + findWorkspaceIdForDirectory(selectedServerId, directory), + confirm: confirmDialogWithCheckbox, + openExistingWorkspace: (workspaceId) => { + const existingWorkspace = findWorkspaceById({ + workspaces: workspacesForServer(selectedServerId), + workspaceId, + }); + if (!existingWorkspace) { + // Vanished between the steer resolving it and the user answering + // (archived from another client). Nothing to start a chat in. + navigateToWorkspace(selectedServerId, workspaceId); + return; + } + return runSubmitNewWorkspace(payload, { existingWorkspace }); + }, + createWorktreeInstead: () => + runSubmitNewWorkspace(payload, { isolationOverride: "worktree" }), + onError: (message) => { + setErrorMessage(message); + toast.error(message); + }, + }); + return; + } + const message = toErrorMessage(error); + setErrorMessage(message); + toast.error(message); + } + }, + [runSubmitNewWorkspace, selectedServerId, t, toast], + ); + + const handleViewDocumentation = useCallback( + (documentationFileName: string) => { + setErrorMessage(null); + setPendingAction("docs"); + void runViewDocumentation({ + readmeFileName: documentationFileName, + findExistingWorkspaceId: (directory) => + findWorkspaceIdForProject(selectedServerId, directory), + ensureWorkspace, + serverId: selectedServerId, + sourceDirectory: selectedSourceDirectory, + onError: (message) => { + setPendingAction(null); + setErrorMessage(message); + toast.error(message); + }, + }); + }, + [ensureWorkspace, selectedServerId, selectedSourceDirectory, toast], + ); + + const renderPickerOption = useCallback( + (props: { + option: ComboboxOptionType; + selected: boolean; + active: boolean; + onPress: () => void; + }) => , + [isPending, itemById], + ); + + const renderProjectOption = useCallback( + (props: { + option: ComboboxOptionType; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ( + + ), + [ + isPending, + projectByOptionId, + projectIconDataByProjectKey, + selectedServerId, + supportsWorkspaceMultiplicity, + ], + ); + + const contentStyle = useMemo( + () => getContentStyle({ isCompact, insetBottom: insets.bottom }), + [isCompact, insets.bottom], + ); + + const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ + mode: "translate", + }); + + const centeredStyle = useMemo( + () => [styles.centered, composerKeyboardStyle], + [composerKeyboardStyle], + ); + + const agentControlsWithDisabled = useMemo( + () => + composerState + ? { + ...composerState.agentControls, + disabled: isPending, + } + : undefined, + [composerState, isPending], + ); + + const pickerEmptyText = + branchSuggestionsQuery.isFetching || githubPrSearchQuery.isFetching + ? t("newWorkspace.refPicker.searching") + : t("newWorkspace.refPicker.noMatchingRefs"); + + const formStack = useNewWorkspaceFormStack({ + isCompact, + isPending, + project: { + anchorRef: projectPickerAnchorRef, + open: openProjectPicker, + options: projectPickerOptions, + triggerLabel: projectTriggerLabel, + selectedProject, + iconDataByProjectKey: projectIconDataByProjectKey, + selectedOptionId: selectedProjectOptionId, + onSelect: handleSelectProjectOption, + openState: projectPickerOpen, + onOpenChange: handleProjectPickerOpenChange, + renderOption: renderProjectOption, + }, + host: { + allHosts, + selectedServerId, + onSelect: handleSelectHost, + openState: hostPickerOpen, + onOpenChange: handleHostPickerOpenChange, + anchorRef: hostPickerAnchorRef, + open: openHostPicker, + }, + isolation: { + anchorRef: isolationPickerAnchorRef, + open: openIsolationPicker, + effectiveIsolation, + options: isolationOptions, + onSelect: handleSelectIsolationOption, + openState: isolationPickerOpen, + onOpenChange: handleIsolationPickerOpenChange, + renderOption: renderIsolationOption, + canCreateWorktree, + }, + base: { + anchorRef: pickerAnchorRef, + open: openPicker, + selectedSourceDirectory, + selectedItem, + triggerLabel, + options, + selectedOptionId, + onSelect: handleSelectOption, + openState: pickerOpen, + onOpenChange: handlePickerOpenChange, + setSearchQuery: setPickerSearchQuery, + emptyText: pickerEmptyText, + renderOption: renderPickerOption, + showRefPicker, + }, + }); + + const composerFooter = useMemo( + () => + checkoutHintPrAttachment ? ( + + ) : undefined, + [ + acceptCheckoutHint, + checkoutHintPrAttachment, + dismissCheckoutHint, + t, + theme.colors.foregroundMuted, + theme.iconSize.sm, + ], + ); + const screenHeaderLeft = useMemo(() => , []); + const viewDocumentationIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foreground], + ); + + return ( + + + + + + + {t("newWorkspace.title")} + + {formStack} + + + {errorMessage ? {errorMessage} : null} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + userSelect: "none", + }, + content: { + position: "relative", + flex: 1, + alignItems: "center", + }, + contentCentered: { + justifyContent: "center", + paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6], + }, + contentCompact: { + justifyContent: "flex-end", + }, + centered: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + }, + composerTitleContainer: { + marginBottom: theme.spacing[8], + paddingLeft: theme.spacing[6], + paddingRight: theme.spacing[4], + }, + composerTitle: { + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + }, + errorText: { + fontSize: theme.fontSize.sm, + color: theme.colors.destructive, + lineHeight: 20, + // Match the composer's own horizontal inset so the message sits centered + // under the input box rather than against the container's outer edge. + paddingHorizontal: theme.spacing[4], + textAlign: "center", + }, + formStack: { + marginBottom: theme.spacing[3], + gap: theme.spacing[2], + }, + viewDocumentationButton: { + alignSelf: "flex-start", + marginLeft: theme.spacing[4], + marginBottom: theme.spacing[4], + }, + formStackDesktop: { + flexDirection: "row", + alignItems: "center", + marginBottom: theme.spacing[3], + // The badge adds its own left padding; offset it so the project icon's left + // edge lands exactly on the "New workspace" title's left edge. + paddingLeft: theme.spacing[4], + gap: theme.spacing[2], + }, + // The row's left inset matches the heading's text x (composerTitleContainer + // paddingLeft) so the control aligns with the "New workspace" glyph. The badge + // adds its own left padding, so the row inset is reduced by that amount. + row: { + flexDirection: "row", + alignItems: "center", + paddingLeft: theme.spacing[4], + gap: theme.spacing[1], + }, + badge: { + flexDirection: "row", + alignItems: "center", + // 1.5x on compact to wrap the badge icons' compact upscale — otherwise the + // theme-scaled icon/text get clipped by the fixed desktop height. + height: compactUp(BADGE_HEIGHT, 1.5), + maxWidth: 240, + overflow: "hidden", + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + gap: theme.spacing[1], + }, + checkoutHintBadge: { + flexDirection: "row", + alignItems: "center", + height: compactUp(BADGE_HEIGHT, 1.5), + maxWidth: 240, + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + gap: theme.spacing[1], + backgroundColor: theme.colors.surface1, + }, + checkoutHintAction: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.full, + }, + badgeHovered: { + backgroundColor: theme.colors.surface2, + }, + badgePressed: { + backgroundColor: theme.colors.surface0, + }, + badgeDisabled: { + opacity: 0.6, + }, + badgeText: { + minWidth: 0, + // Explicit compact bump matching other picker triggers. + fontSize: { + xs: theme.fontSize.sm + 2, + md: theme.fontSize.sm, + }, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.popoverForeground, + }, + badgeIconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + projectIcon: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + }, + projectIconFallback: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + alignItems: "center", + justifyContent: "center", + }, + projectIconFallbackText: { + // Single uppercase initial inside an iconSize.md (16px) square — below the + // smallest font-size token, so it stays a literal sized to the box. + fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE, + fontWeight: "600", + }, + rowIconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + }, + hostStatusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, +})); diff --git a/packages/app/src/screens/new-workspace-view-documentation.test.ts b/packages/app/src/screens/new-workspace-view-documentation.test.ts new file mode 100644 index 000000000..7d631853c --- /dev/null +++ b/packages/app/src/screens/new-workspace-view-documentation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; + +const navigateToPreparedWorkspaceTab = vi.fn(); +const setFileViewModeFor = vi.fn(); + +vi.mock("@/utils/workspace-navigation", () => ({ + navigateToPreparedWorkspaceTab: (input: unknown) => navigateToPreparedWorkspaceTab(input), +})); +vi.mock("@/stores/file-view-store", () => ({ + setFileViewModeFor: (input: unknown) => setFileViewModeFor(input), +})); +vi.mock("@/stores/workspace-tabs-store", () => ({ + buildWorkspaceTabPersistenceKey: (input: { serverId: string; workspaceId: string }) => + `${input.serverId}:${input.workspaceId}`, +})); +vi.mock("@/workspace/file-open", () => ({ + createWorkspaceFileTabTarget: (input: { path: string }) => ({ kind: "file", path: input.path }), +})); + +const { runViewDocumentation } = await import("./new-workspace-view-documentation"); + +describe("runViewDocumentation", () => { + it("opens the README in the workspace already backing the directory instead of creating one", async () => { + const ensureWorkspace = vi.fn(); + const onError = vi.fn(); + + await runViewDocumentation({ + readmeFileName: "README.md", + findExistingWorkspaceId: () => "workspace-existing", + ensureWorkspace, + serverId: "server-abc", + sourceDirectory: "/sample/repo", + onError, + }); + + expect(ensureWorkspace).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(navigateToPreparedWorkspaceTab).toHaveBeenCalledWith({ + serverId: "server-abc", + workspaceId: "workspace-existing", + target: { kind: "file", path: "README.md" }, + }); + }); + + it("creates a workspace when the directory does not back one yet", async () => { + navigateToPreparedWorkspaceTab.mockClear(); + const ensureWorkspace = vi.fn().mockResolvedValue({ id: "workspace-new" }); + const onError = vi.fn(); + + await runViewDocumentation({ + readmeFileName: "README.md", + findExistingWorkspaceId: () => null, + ensureWorkspace, + serverId: "server-abc", + sourceDirectory: "/sample/repo", + onError, + }); + + expect(ensureWorkspace).toHaveBeenCalledWith({ + cwd: "/sample/repo", + prompt: "", + attachments: [], + withInitialAgent: false, + }); + expect(onError).not.toHaveBeenCalled(); + expect(navigateToPreparedWorkspaceTab).toHaveBeenCalledWith({ + serverId: "server-abc", + workspaceId: "workspace-new", + target: { kind: "file", path: "README.md" }, + }); + }); +}); diff --git a/packages/app/src/screens/new-workspace-view-documentation.ts b/packages/app/src/screens/new-workspace-view-documentation.ts new file mode 100644 index 000000000..32ed5d850 --- /dev/null +++ b/packages/app/src/screens/new-workspace-view-documentation.ts @@ -0,0 +1,93 @@ +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import type { AgentAttachment } from "@otto-code/protocol/messages"; +import type { DaemonClient } from "@otto-code/client/internal/daemon-client"; +import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { createWorkspaceFileTabTarget } from "@/workspace/file-open"; +import { toErrorMessage } from "@/utils/error-messages"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { setFileViewModeFor } from "@/stores/file-view-store"; + +const README_FILENAME_PATTERN = /^readme(\.md)?$/i; + +export interface ResolveReadmeFileNameInput { + sourceDirectory: string | null; + getClient: () => Pick; +} + +export async function resolveReadmeFileName( + input: ResolveReadmeFileNameInput, +): Promise { + const { sourceDirectory, getClient } = input; + if (!sourceDirectory) { + throw new Error("Choose a project"); + } + const directory = await getClient().listDirectory(sourceDirectory, "."); + const readmeEntry = directory.entries.find( + (entry) => entry.kind === "file" && README_FILENAME_PATTERN.test(entry.name), + ); + return readmeEntry?.name ?? null; +} + +export interface RunViewDocumentationInput { + readmeFileName: string; + /** + * Id of any live workspace for the project rooted at `sourceDirectory`, if any. + * Reading a README never justifies a workspace of its own: the daemon rejects + * a second workspace on an occupied directory (WorkspaceDirectoryOccupiedError), + * so without this the button just surfaces that error instead of opening the + * file the user asked for. Reuse first; create only when nothing is there. + * + * Deliberately project-wide, not root-only. A project whose only workspace is a + * worktree has nothing at the root, so a root-only lookup misses, creation then + * *succeeds* (the root is unoccupied), and reading a README silently leaves a + * spare workspace behind. See `findWorkspaceForProject`. + */ + findExistingWorkspaceId: (sourceDirectory: string) => string | null; + ensureWorkspace: (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + withInitialAgent: boolean; + }) => Promise>; + serverId: string; + sourceDirectory: string | null; + onError: (message: string) => void; +} + +export async function runViewDocumentation(input: RunViewDocumentationInput): Promise { + const { + readmeFileName, + findExistingWorkspaceId, + ensureWorkspace, + serverId, + sourceDirectory, + onError, + } = input; + try { + const existingWorkspaceId = sourceDirectory ? findExistingWorkspaceId(sourceDirectory) : null; + const workspaceId = + existingWorkspaceId ?? + ( + await ensureWorkspace({ + cwd: sourceDirectory ?? "", + prompt: "", + attachments: [], + withInitialAgent: false, + }) + ).id; + const persistenceKey = buildWorkspaceTabPersistenceKey({ + serverId, + workspaceId, + }); + if (persistenceKey) { + setFileViewModeFor({ persistenceKey, path: readmeFileName, mode: "preview" }); + } + navigateToPreparedWorkspaceTab({ + serverId, + workspaceId, + target: createWorkspaceFileTabTarget({ path: readmeFileName }), + }); + } catch (error) { + onError(toErrorMessage(error)); + } +} diff --git a/packages/app/src/screens/new-workspace/project-picker.ts b/packages/app/src/screens/new-workspace/project-picker.ts new file mode 100644 index 000000000..f408c1913 --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-picker.ts @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox"; +import { isWorkspaceArchivePending } from "@/contexts/session-workspace-upserts"; +import { + filterWorkspaceProjectsForHost, + getHostProjectSourceDirectory, + resolveInitialWorkspaceProject, + type HostProjectListItem, +} from "@/projects/host-projects"; +import { + createManualProjectSelectionContextKey, + createProjectSelectionContextKey, + createProjectSelection, + reconcileProjectSelection, + resolveInitialProjectSelectionSource, + resolveProjectSelection, + type ProjectSelection, + type ProjectSelectionContext, +} from "./project-selection"; + +const PROJECT_OPTION_PREFIX = "project:"; + +interface NewWorkspaceProjectPickerInput { + selectedServerId: string; + projects: HostProjectListItem[]; + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; + allowAllProjects: boolean; +} + +interface NewWorkspaceProjectPickerState { + selectedProject: HostProjectListItem | null; + selectedSourceDirectory: string | null; + projectPickerOptions: ComboboxOptionType[]; + projectByOptionId: Map; + selectedProjectOptionId: string; + projectTriggerLabel: string; + handleSelectProjectOption: (id: string) => void; +} + +function projectOptionId(projectId: string): string { + return `${PROJECT_OPTION_PREFIX}${projectId}`; +} + +function computeProjectOptionData(projects: readonly HostProjectListItem[]) { + const projectByOptionId = new Map(); + const options = projects.map((project) => { + const id = projectOptionId(project.projectKey); + projectByOptionId.set(id, project); + return { id, label: project.projectName }; + }); + return { options, projectByOptionId }; +} + +function resolveWorkspaceIdFromProjectWorkspaceKey(input: { + selectedServerId: string; + workspaceKey: string; +}): string | null { + const prefix = `${input.selectedServerId}:`; + return input.workspaceKey.startsWith(prefix) ? input.workspaceKey.slice(prefix.length) : null; +} + +function hasPendingArchiveForProject(input: { + selectedServerId: string; + project: HostProjectListItem; +}): boolean { + for (const workspaceKey of input.project.workspaceKeys) { + const workspaceId = resolveWorkspaceIdFromProjectWorkspaceKey({ + selectedServerId: input.selectedServerId, + workspaceKey, + }); + if ( + workspaceId && + isWorkspaceArchivePending({ serverId: input.selectedServerId, workspaceId }) + ) { + return true; + } + } + + return false; +} + +export function useNewWorkspaceProjectPicker({ + selectedServerId, + projects, + routeProject, + lastActiveProject, + allowAllProjects, +}: NewWorkspaceProjectPickerInput): NewWorkspaceProjectPickerState { + const selectableProjects = useMemo( + () => + filterWorkspaceProjectsForHost({ projects, serverId: selectedServerId, allowAllProjects }), + [allowAllProjects, projects, selectedServerId], + ); + const initialProject = useMemo( + () => + resolveInitialWorkspaceProject({ + routeProject, + lastActiveProject, + projects: selectableProjects, + serverId: selectedServerId, + allowAllProjects, + }), + [allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId], + ); + + const routeProjectKey = routeProject?.projectKey ?? null; + const selectionContextKey = createProjectSelectionContextKey({ + selectedServerId, + routeProjectKey, + allowAllProjects, + }); + const manualSelectionContextKey = createManualProjectSelectionContextKey({ + selectedServerId, + routeProjectKey, + }); + const shouldPreserveMissingProject = useCallback( + (project: HostProjectListItem) => + hasPendingArchiveForProject({ + selectedServerId, + project, + }), + [selectedServerId], + ); + const selectionContext = useMemo( + () => ({ + contextKey: selectionContextKey, + manualContextKey: manualSelectionContextKey, + initialProject, + initialProjectSource: resolveInitialProjectSelectionSource({ + initialProject, + routeProject, + lastActiveProject, + }), + projects: selectableProjects, + routeProject, + lastActiveProject, + shouldPreserveMissingProject, + }), + [ + initialProject, + lastActiveProject, + manualSelectionContextKey, + routeProject, + selectableProjects, + selectionContextKey, + shouldPreserveMissingProject, + ], + ); + const [projectSelection, setProjectSelection] = useState(() => + createProjectSelection(selectionContext), + ); + + useEffect(() => { + setProjectSelection((current) => reconcileProjectSelection(current, selectionContext)); + }, [selectionContext]); + + const activeSelection = reconcileProjectSelection(projectSelection, selectionContext); + const selectedProject = resolveProjectSelection(activeSelection, selectionContext); + const { options: projectPickerOptions, projectByOptionId } = useMemo( + () => computeProjectOptionData(selectableProjects), + [selectableProjects], + ); + const handleSelectProjectOption = useCallback( + (id: string) => { + const project = projectByOptionId.get(id); + if (!project) return; + if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return; + setProjectSelection({ + contextKey: manualSelectionContextKey, + projectKey: project.projectKey, + project, + source: "manual", + }); + }, + [allowAllProjects, manualSelectionContextKey, projectByOptionId], + ); + + return { + selectedProject, + selectedSourceDirectory: selectedProject + ? getHostProjectSourceDirectory(selectedProject, selectedServerId) + : null, + projectPickerOptions, + projectByOptionId, + selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "", + projectTriggerLabel: selectedProject?.projectName ?? "Choose project", + handleSelectProjectOption, + }; +} diff --git a/packages/app/src/screens/new-workspace/project-selection.test.ts b/packages/app/src/screens/new-workspace/project-selection.test.ts new file mode 100644 index 000000000..e9dae3258 --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-selection.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; +import type { HostProjectListItem } from "@/projects/host-projects"; +import { + createManualProjectSelectionContextKey, + createProjectSelectionContextKey, + createProjectSelection, + reconcileProjectSelection, + resolveInitialProjectSelectionSource, + resolveProjectSelection, + type ProjectSelection, + type ProjectSelectionContext, +} from "./project-selection"; + +function project(projectKey: string, serverId = "host"): HostProjectListItem { + return { + projectKey, + projectName: projectKey, + projectKind: "git", + iconWorkingDir: `/work/${projectKey}`, + hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }], + workspaceKeys: [], + }; +} + +function context( + input: Partial & { + initialProject: HostProjectListItem | null; + projects: HostProjectListItem[]; + }, +): ProjectSelectionContext { + const contextKey = input.contextKey ?? "host:"; + const routeProject = input.routeProject ?? null; + const lastActiveProject = input.lastActiveProject ?? null; + return { + contextKey, + manualContextKey: input.manualContextKey ?? contextKey, + routeProject, + lastActiveProject, + initialProjectSource: + input.initialProjectSource ?? + resolveInitialProjectSelectionSource({ + initialProject: input.initialProject, + routeProject, + lastActiveProject, + }), + shouldPreserveMissingProject: () => false, + ...input, + }; +} + +describe("reconcileProjectSelection", () => { + it("keeps a still-selectable project when the default moves after archive", () => { + const remembered = project("remembered"); + const other = project("other"); + const current = createProjectSelection( + context({ initialProject: remembered, projects: [remembered, other] }), + ); + const afterArchive = context({ + initialProject: other, + projects: [other, remembered], + }); + + const reconciled = reconcileProjectSelection(current, afterArchive); + + expect(reconciled).toEqual({ + contextKey: "host:", + projectKey: remembered.projectKey, + project: remembered, + source: "initial", + }); + expect(resolveProjectSelection(reconciled, afterArchive)).toEqual(remembered); + }); + + it("resets stale selection when the route project context changes", () => { + const manual = project("manual"); + const routeProject = project("route-project"); + const current: ProjectSelection = { + contextKey: "host:previous-route", + projectKey: manual.projectKey, + project: manual, + source: "manual", + }; + const nextContext = context({ + contextKey: "host:route-project", + initialProject: routeProject, + projects: [manual, routeProject], + routeProject, + }); + + expect(reconcileProjectSelection(current, nextContext)).toEqual({ + contextKey: "host:route-project", + projectKey: routeProject.projectKey, + project: routeProject, + source: "initial", + }); + }); + + it("hydrates an empty initial selection when projects arrive", () => { + const initialProject = project("hydrated"); + const current = createProjectSelection(context({ initialProject: null, projects: [] })); + const hydratedContext = context({ + initialProject, + projects: [initialProject], + }); + + expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ + contextKey: "host:", + projectKey: initialProject.projectKey, + project: initialProject, + source: "initial", + }); + }); + + it("stores hydrated project snapshots before archive gaps", () => { + const routeProject = project("route-project"); + const hydratedProject: HostProjectListItem = { + ...routeProject, + workspaceKeys: ["host:workspace"], + }; + const current = createProjectSelection( + context({ initialProject: routeProject, projects: [], routeProject }), + ); + const afterHydration = context({ + initialProject: hydratedProject, + projects: [hydratedProject], + routeProject, + }); + + const hydratedSelection = reconcileProjectSelection(current, afterHydration); + + expect(hydratedSelection).toEqual({ + contextKey: "host:", + projectKey: hydratedProject.projectKey, + project: hydratedProject, + source: "initial", + }); + + const archiveGap = context({ + initialProject: routeProject, + projects: [], + routeProject, + shouldPreserveMissingProject: (candidate) => + candidate.workspaceKeys.includes("host:workspace"), + }); + + expect(resolveProjectSelection(hydratedSelection, archiveGap)).toEqual(hydratedProject); + }); + + it("resets an automatic fallback when the remembered project hydrates", () => { + const fallback = project("fallback"); + const remembered = project("remembered"); + const current = createProjectSelection( + context({ initialProject: fallback, projects: [fallback, remembered] }), + ); + const afterRememberedHydration = context({ + initialProject: remembered, + projects: [fallback, remembered], + lastActiveProject: remembered, + }); + + expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({ + contextKey: "host:", + projectKey: remembered.projectKey, + project: remembered, + source: "initial", + }); + }); + + it("keeps manual selections when the remembered project hydrates", () => { + const manual = project("manual"); + const remembered = project("remembered"); + const current: ProjectSelection = { + contextKey: "host:", + projectKey: manual.projectKey, + project: manual, + source: "manual", + }; + const afterRememberedHydration = context({ + initialProject: remembered, + projects: [manual, remembered], + lastActiveProject: remembered, + }); + + expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current); + }); + + it("resets fallback selection when host project capability changes", () => { + const fallback = project("git-fallback"); + const remembered = project("remembered-directory"); + const current = createProjectSelection( + context({ + contextKey: createProjectSelectionContextKey({ + selectedServerId: "host", + routeProjectKey: null, + allowAllProjects: false, + }), + initialProject: fallback, + projects: [fallback, remembered], + }), + ); + const afterCapabilityHydration = context({ + contextKey: createProjectSelectionContextKey({ + selectedServerId: "host", + routeProjectKey: null, + allowAllProjects: true, + }), + initialProject: remembered, + projects: [fallback, remembered], + }); + + expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({ + contextKey: "host:all-projects:", + projectKey: remembered.projectKey, + project: remembered, + source: "initial", + }); + }); + + it("keeps a still-selectable manual selection when host project capability changes", () => { + const fallback = project("git-fallback"); + const manual = project("manual-choice"); + const remembered = project("remembered-directory"); + const current: ProjectSelection = { + contextKey: createManualProjectSelectionContextKey({ + selectedServerId: "host", + routeProjectKey: null, + }), + projectKey: manual.projectKey, + project: manual, + source: "manual", + }; + const afterCapabilityHydration = context({ + contextKey: createProjectSelectionContextKey({ + selectedServerId: "host", + routeProjectKey: null, + allowAllProjects: true, + }), + manualContextKey: createManualProjectSelectionContextKey({ + selectedServerId: "host", + routeProjectKey: null, + }), + initialProject: remembered, + projects: [fallback, manual, remembered], + }); + + const reconciled = reconcileProjectSelection(current, afterCapabilityHydration); + + expect(reconciled).toEqual(current); + expect(resolveProjectSelection(reconciled, afterCapabilityHydration)).toEqual(manual); + }); + + it("keeps the selected project snapshot during a pending archive gap", () => { + const remembered = project("remembered"); + const fallback = project("fallback"); + const current = createProjectSelection( + context({ initialProject: remembered, projects: [remembered] }), + ); + const withoutRemembered = context({ + initialProject: fallback, + projects: [fallback], + shouldPreserveMissingProject: (candidate) => candidate.projectKey === remembered.projectKey, + }); + + const reconciled = reconcileProjectSelection(current, withoutRemembered); + + expect(reconciled).toEqual(current); + expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered); + }); + + it("falls back when the selected project disappears without a pending archive", () => { + const remembered = project("remembered"); + const fallback = project("fallback"); + const current = createProjectSelection( + context({ initialProject: remembered, projects: [remembered] }), + ); + const withoutRemembered = context({ + initialProject: fallback, + projects: [fallback], + }); + + expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({ + contextKey: "host:", + projectKey: fallback.projectKey, + project: fallback, + source: "initial", + }); + }); + + it("resolves manual selections from selectable projects, not route or remembered projects", () => { + const manual = project("manual"); + const routeProject = project("route-project"); + const remembered = project("remembered"); + const current: ProjectSelection = { + contextKey: "host:route-project", + projectKey: manual.projectKey, + project: manual, + source: "manual", + }; + const selectionContext = context({ + contextKey: "host:route-project", + initialProject: routeProject, + projects: [manual], + routeProject, + lastActiveProject: remembered, + }); + + expect(resolveProjectSelection(current, selectionContext)).toEqual(manual); + }); +}); diff --git a/packages/app/src/screens/new-workspace/project-selection.ts b/packages/app/src/screens/new-workspace/project-selection.ts new file mode 100644 index 000000000..58ed75ae4 --- /dev/null +++ b/packages/app/src/screens/new-workspace/project-selection.ts @@ -0,0 +1,162 @@ +import type { HostProjectListItem } from "@/projects/host-projects"; + +export type ProjectSelectionSource = "initial" | "manual"; +export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null; + +export interface ProjectSelection { + contextKey: string; + projectKey: string | null; + project: HostProjectListItem | null; + source: ProjectSelectionSource; +} + +export interface ProjectSelectionContext { + contextKey: string; + manualContextKey: string; + initialProject: HostProjectListItem | null; + initialProjectSource: InitialProjectSelectionSource; + projects: HostProjectListItem[]; + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; + shouldPreserveMissingProject: (project: HostProjectListItem) => boolean; +} + +export function createProjectSelectionContextKey(input: { + selectedServerId: string; + routeProjectKey: string | null; + allowAllProjects: boolean; +}): string { + const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects"; + return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`; +} + +export function createManualProjectSelectionContextKey(input: { + selectedServerId: string; + routeProjectKey: string | null; +}): string { + return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`; +} + +export function createProjectSelection({ + contextKey, + initialProject, +}: ProjectSelectionContext): ProjectSelection { + return { + contextKey, + projectKey: initialProject?.projectKey ?? null, + project: initialProject, + source: "initial", + }; +} + +export function resolveInitialProjectSelectionSource(input: { + initialProject: HostProjectListItem | null; + routeProject: HostProjectListItem | null; + lastActiveProject: HostProjectListItem | null; +}): InitialProjectSelectionSource { + if (!input.initialProject) { + return null; + } + if (input.routeProject?.projectKey === input.initialProject.projectKey) { + return "route"; + } + if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) { + return "lastActive"; + } + return "fallback"; +} + +function resolveProjectSelectionKey(selection: ProjectSelection): string | null { + const projectKey = selection.projectKey?.trim() ?? ""; + return projectKey || null; +} + +function resolveSelectedProjectFromInitialInputs( + projectKey: string, + context: ProjectSelectionContext, +): HostProjectListItem | null { + return ( + (context.routeProject?.projectKey === projectKey ? context.routeProject : null) ?? + (context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null) + ); +} + +function refreshSelectionProject( + selection: ProjectSelection, + project: HostProjectListItem, +): ProjectSelection { + if (selection.projectKey === project.projectKey && selection.project === project) { + return selection; + } + return { + ...selection, + projectKey: project.projectKey, + project, + }; +} + +function shouldResetInitialFallbackSelection( + selection: ProjectSelection, + context: ProjectSelectionContext, +): boolean { + if ( + selection.source !== "initial" || + !context.initialProject || + context.initialProjectSource !== "lastActive" + ) { + return false; + } + + return selection.projectKey !== context.initialProject.projectKey; +} + +export function resolveProjectSelection( + selection: ProjectSelection, + context: ProjectSelectionContext, +): HostProjectListItem | null { + const projectKey = resolveProjectSelectionKey(selection); + if (!projectKey) { + return null; + } + + const selectableProject = context.projects.find((project) => project.projectKey === projectKey); + if (selectableProject) { + return selectableProject; + } + + if ( + selection.project?.projectKey === projectKey && + context.shouldPreserveMissingProject(selection.project) + ) { + return selection.project; + } + + if (selection.source !== "manual") { + return resolveSelectedProjectFromInitialInputs(projectKey, context); + } + + return null; +} + +export function reconcileProjectSelection( + current: ProjectSelection, + context: ProjectSelectionContext, +): ProjectSelection { + const initialSelection = createProjectSelection(context); + const currentContextKey = + current.source === "manual" ? context.manualContextKey : context.contextKey; + if (current.contextKey !== currentContextKey) { + return initialSelection; + } + + if (shouldResetInitialFallbackSelection(current, context)) { + return initialSelection; + } + + const resolvedProject = resolveProjectSelection(current, context); + if (resolvedProject) { + return refreshSelectionProject(current, resolvedProject); + } + + return initialSelection; +} diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx new file mode 100644 index 000000000..682cd1659 --- /dev/null +++ b/packages/app/src/screens/open-project-screen.tsx @@ -0,0 +1,360 @@ +import { useCallback, useEffect, useRef, useState, type ComponentType, type Ref } from "react"; +import { useTranslation } from "react-i18next"; +import { View, Text, Pressable, ScrollView } from "react-native"; +import { useTutorialAnchor } from "@/tutorial/use-tutorial-anchor"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useRouter } from "expo-router"; +import { FolderOpen, Inbox, Plug, Smartphone } from "@/components/icons/material-icons"; +import { OttoLogoWink } from "@/components/icons/otto-logo"; +import { CommunityLinks } from "@/components/community-links"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; +import { useHostChooser } from "@/hosts/host-chooser"; +import { usePanelStore } from "@/stores/panel-store"; +import { + useIsCompactFormFactor, + HEADER_INNER_HEIGHT, + HEADER_INNER_HEIGHT_MOBILE, + HEADER_TOP_PADDING_MOBILE, +} from "@/constants/layout"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; +import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes"; +import { ImportSessionSheet } from "@/components/import-session-sheet"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { isWeb } from "@/constants/platform"; +import { useOpenProject } from "@/hooks/use-open-project"; +import type { Href } from "expo-router"; + +interface HomeQuote { + text: string; + attribution: string; +} + +// Picked once per app launch and reused for every render/remount of this screen in the same run. +let sessionQuoteIndex: number | undefined; + +function getSessionQuoteIndex(count: number): number { + if (sessionQuoteIndex === undefined || sessionQuoteIndex >= count) { + sessionQuoteIndex = Math.floor(Math.random() * count); + } + return sessionQuoteIndex; +} + +export function OpenProjectScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList); + const openProjectPicker = useOpenProjectPicker(); + const chooseHost = useHostChooser(); + const localServerId = useLocalDaemonServerId(); + const [importServerId, setImportServerId] = useState(null); + const importClient = useHostRuntimeClient(importServerId ?? ""); + const openImportedProject = useOpenProject(importServerId); + const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false); + const [isImportSheetOpen, setIsImportSheetOpen] = useState(false); + + const quotes = t("openProject.quotes", { returnObjects: true }) as HomeQuote[]; + const quote = quotes[getSessionQuoteIndex(quotes.length)]; + + const isCompactLayout = useIsCompactFormFactor(); + const addProjectAnchorRef = useTutorialAnchor("add-project"); + + useEffect(() => { + if (!isCompactLayout) { + openDesktopAgentList(); + } + }, [isCompactLayout, openDesktopAgentList]); + + const handleOpenPicker = useCallback(() => { + void openProjectPicker(); + }, [openProjectPicker]); + + const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []); + const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []); + + const handleOpenImportSession = useCallback(() => { + chooseHost({ + title: "Import from host", + onChooseHost: (serverId) => { + setImportServerId(serverId); + setIsImportSheetOpen(true); + }, + }); + }, [chooseHost]); + const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []); + + const handleImported = useCallback( + (agent: { id: string; cwd: string }) => { + if (!importServerId) return; + void (async () => { + const result = await openImportedProject(agent.cwd); + if (result.ok) { + router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href); + } + })(); + }, + [importServerId, openImportedProject, router], + ); + + // Web gets the themed overlay scrollbar (auto-hiding, no gutter) at every + // width; native keeps its own indicator, which already auto-hides. + const scrollRef = useRef(null); + const webScrollbar = useWebScrollViewScrollbar(scrollRef, { enabled: isWeb }); + + const handleOpenProviders = useCallback(() => { + chooseHost({ + title: "Choose host", + onChooseHost: (serverId) => { + router.push(buildSettingsHostSectionRoute(serverId, "providers")); + }, + }); + }, [chooseHost, router]); + + return ( + + + {/* Wrapped so the overlay scrollbar can position against the scroll region. */} + + + + + + + + + + “{quote.text}” {quote.attribution} + + + + + + + {localServerId ? ( + + ) : null} + + + + + + + {webScrollbar.overlay} + + + + + ); +} + +interface HomeTileProps { + icon: ComponentType<{ size: number; color: string }>; + title: string; + description: string; + onPress: () => void; + testID?: string; + anchorRef?: Ref; + accent?: boolean; +} + +const TILE_SHADOW_DARK = "0 0 5px rgba(0, 0, 0, 0.2)"; +const TILE_SHADOW_LIGHT = "0 0 5px rgba(0, 0, 0, 0.1)"; + +function HomeTile({ + icon: Icon, + title, + description, + onPress, + testID, + anchorRef, + accent, +}: HomeTileProps) { + // useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once). + const { theme } = useUnistyles(); + const [hovered, setHovered] = useState(false); + const handleHoverIn = useCallback(() => setHovered(true), []); + const handleHoverOut = useCallback(() => setHovered(false), []); + + const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted; + + // The shadow must flow through React, not a `theme.colorScheme` ternary in the + // StyleSheet factory: on web the factory's non-color values are computed once at + // module load against the then-active theme and freeze on the startup scheme + // (docs/unistyles.md). + const boxShadow = theme.colorScheme === "dark" ? TILE_SHADOW_DARK : TILE_SHADOW_LIGHT; + + const pressableStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + styles.tile, + { boxShadow }, + hovered && styles.tileHovered, + pressed && styles.tilePressed, + ], + [hovered, boxShadow], + ); + + return ( + + + + {title} + {description} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + userSelect: "none", + }, + scrollWrap: { + flex: 1, + minHeight: 0, + position: "relative", + }, + scroll: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + }, + content: { + position: "relative", + flexGrow: 1, + justifyContent: { xs: "flex-start", md: "center" }, + alignItems: "center", + gap: 0, + padding: theme.spacing[6], + // Compact: the logo is the first thing under the header, so it sits flush — + // any breathing room comes from the logo art's own internal margin. + paddingTop: { xs: 0, md: theme.spacing[6] }, + // The header sits above this centering region, so reserve the same height + // at the bottom — otherwise the block centers in the below-header space + // and reads as sitting too low on the page. + paddingBottom: { xs: theme.spacing[4], md: theme.spacing[4] + HEADER_INNER_HEIGHT }, + }, + logo: { + marginBottom: theme.spacing[4], + }, + quote: { + alignItems: "center", + maxWidth: 380, + marginBottom: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + }, + quoteText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontStyle: "italic", + textAlign: "center", + lineHeight: 20, + }, + tiles: { + marginTop: { xs: theme.spacing[4], md: theme.spacing[6] }, + width: "100%", + maxWidth: 452, + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "flex-start", + gap: theme.spacing[3], + }, + tile: { + width: { xs: "100%", md: 220 }, + minHeight: { xs: 0, md: 132 }, + padding: theme.spacing[4], + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.xl, + gap: theme.spacing[3], + }, + tileHovered: { + backgroundColor: theme.colors.surface2, + borderColor: theme.colors.borderAccent, + }, + tilePressed: { + opacity: 0.85, + }, + tileText: { + gap: theme.spacing[1], + }, + tileTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, + tileDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: 18, + }, + communityRow: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 0, + paddingTop: theme.spacing[2], + paddingBottom: { + xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2], + md: HEADER_INNER_HEIGHT + theme.spacing[2], + }, + }, +})); diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx new file mode 100644 index 000000000..af4144558 --- /dev/null +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -0,0 +1,1761 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Pressable, Text, TextInput, View } from "react-native"; +import { router } from "expo-router"; +import { useNavigation, usePreventRemove } from "@react-navigation/native"; +import { StyleSheet } from "react-native-unistyles"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeft, + Check, + ChevronDown, + MoreVertical, + Pencil, + Plus, + X, +} from "@/components/icons/material-icons"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker"; +import type { + OttoConfigRaw, + OttoConfigRevision, + ProjectConfigRpcError, +} from "@otto-code/protocol/messages"; +import type { DaemonClient } from "@otto-code/client/internal/daemon-client"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Alert } from "@/components/ui/alert"; +import { ExternalLink } from "@/components/ui/external-link"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Switch } from "@/components/ui/switch"; +import { TextArea } from "@/components/ui/text-area"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { SettingsTextAreaCard } from "@/components/settings-textarea"; +import { SettingsGroup } from "@/screens/settings/settings-group"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { settingsStyles } from "@/styles/settings"; +import { isNative } from "@/constants/platform"; +import { useProjects } from "@/hooks/use-projects"; +import { useIsDeveloperMode } from "@/hooks/use-interface-mode"; +import { useHostFeature } from "@/runtime/host-features"; +import { useSessionStore } from "@/stores/session-store"; +import { + canonicalLinkKey, + projectLinksQueryKey, + useProjectLinkSet, +} from "@/projects/project-links"; +import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { useToast } from "@/contexts/toast-context"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { + applyDraftToConfig, + configToDraft, + METADATA_PROMPT_KEYS, + type LifecycleOriginalKind, + type MetadataPromptKey, + type ProjectConfigDraft, + type ProjectScriptDraft, +} from "@/utils/project-config-form"; +import { buildProjectsSettingsRoute } from "@/utils/host-routes"; +import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; +import { useIconSize } from "@/styles/theme"; + +const SCRIPT_SERVICE_TYPE = "service"; + +interface MetadataPromptField { + titleKey: string; + placeholderKey: string; + sectionTestID: string; + inputTestID: string; +} + +const METADATA_PROMPT_FIELDS: Record = { + branchName: { + titleKey: "settings.project.metadata.branchName", + placeholderKey: "settings.project.metadata.branchNamePlaceholder", + sectionTestID: "metadata-prompt-branch-name-section", + inputTestID: "metadata-prompt-branch-name-input", + }, + commitMessage: { + titleKey: "settings.project.metadata.commitMessage", + placeholderKey: "settings.project.metadata.commitMessagePlaceholder", + sectionTestID: "metadata-prompt-commit-message-section", + inputTestID: "metadata-prompt-commit-message-input", + }, + pullRequest: { + titleKey: "settings.project.metadata.pullRequest", + placeholderKey: "settings.project.metadata.pullRequestPlaceholder", + sectionTestID: "metadata-prompt-pull-request-section", + inputTestID: "metadata-prompt-pull-request-input", + }, +}; + +const WORKTREE_DOCS_URL = "https://otto-code.me/docs/worktrees"; + +type ReadProjectConfigData = Awaited>; + +// What the header save button needs from the (separately mounted) config form. +interface ProjectFormSaveState { + isDirty: boolean; + isSaving: boolean; + canSave: boolean; + save: () => void; +} + +/** + * Shared discard/keep-editing confirmation for leaving project settings with + * unsaved changes. Used by this screen's own back affordance and by the + * settings shell's exit handlers (sidebar, back header). + */ +export function confirmDiscardProjectSettingsChanges(t: TFunction): Promise { + return confirmDialog({ + title: t("settings.project.unsavedChanges.title"), + message: t("settings.project.unsavedChanges.message"), + confirmLabel: t("settings.project.unsavedChanges.discard"), + cancelLabel: t("settings.project.unsavedChanges.keepEditing"), + destructive: true, + }); +} + +export interface ProjectSettingsScreenProps { + projectKey: string; + // Reports whether the config form has unsaved changes, so the settings shell + // can guard its own exit affordances (sidebar navigation, back header). + onDirtyChange?: (dirty: boolean) => void; +} + +export default function ProjectSettingsScreen({ + projectKey, + onDirtyChange, +}: ProjectSettingsScreenProps) { + const { projects } = useProjects(); + const project = useMemo( + () => projects.find((entry) => entry.projectKey === projectKey), + [projects, projectKey], + ); + const editableHosts = useMemo(() => filterEditableHosts(project), [project]); + + const [selectedServerId, setSelectedServerId] = useState( + () => editableHosts[0]?.serverId ?? "", + ); + + useEffect(() => { + const stillValid = editableHosts.some((host) => host.serverId === selectedServerId); + if (!stillValid) { + setSelectedServerId(editableHosts[0]?.serverId ?? ""); + } + }, [editableHosts, selectedServerId]); + + const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId); + const isHostGone = + Boolean(selectedServerId) && + (selectedSnapshot?.connectionStatus === "offline" || + selectedSnapshot?.connectionStatus === "error"); + + const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId); + const client = useHostRuntimeClient(selectedHost?.serverId ?? ""); + + if (!project || editableHosts.length === 0 || !selectedHost || !client) { + return ; + } + + return ( + + ); +} + +function filterEditableHosts(project: ProjectSummary | undefined): ProjectHostEntry[] { + if (!project) return []; + return project.hosts.filter( + (host) => host.isOnline && host.serverId.trim().length > 0 && host.repoRoot.trim().length > 0, + ); +} + +function navigateBackToProjects() { + router.navigate(buildProjectsSettingsRoute()); +} + +function NoEditableTarget() { + const { t } = useTranslation(); + return ( + + + {t("settings.project.noEditableTarget")} + + + ); +} + +function BackToProjectsButton({ onPress }: { onPress?: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + +interface ProjectSettingsBodyProps { + project: ProjectSummary; + hosts: ProjectHostEntry[]; + selectedHost: ProjectHostEntry; + onSelectHost: (serverId: string) => void; + client: DaemonClient; + isHostGone: boolean; + onDirtyChange?: (dirty: boolean) => void; +} + +function ProjectSettingsBody({ + project, + hosts, + selectedHost, + onSelectHost, + client, + isHostGone, + onDirtyChange, +}: ProjectSettingsBodyProps) { + const { t } = useTranslation(); + const navigation = useNavigation(); + const [saveState, setSaveState] = useState(null); + const isFormDirty = saveState?.isDirty ?? false; + + useEffect(() => { + onDirtyChange?.(isFormDirty); + return () => onDirtyChange?.(false); + }, [onDirtyChange, isFormDirty]); + + // Native: a single route-removal guard covers hardware back, swipe-back, and + // any in-app navigation that pops this screen. Web keeps the guard in the + // explicit exit handlers instead — preventing removal there fights the + // browser's URL, which expo-router does not recover from (see + // docs/expo-router.md on route-tree fragility). + const handlePreventRemove = useCallback( + ({ data }: { data: { action: Parameters[0] } }) => { + void (async () => { + if (await confirmDiscardProjectSettingsChanges(t)) { + navigation.dispatch(data.action); + } + })(); + }, + [navigation, t], + ); + usePreventRemove(isNative && isFormDirty, handlePreventRemove); + + const handleBackToProjects = useCallback(() => { + // Native removal is guarded by usePreventRemove above; don't double-prompt. + if (isNative || !isFormDirty) { + navigateBackToProjects(); + return; + } + void (async () => { + if (await confirmDiscardProjectSettingsChanges(t)) { + navigateBackToProjects(); + } + })(); + }, [isFormDirty, t]); + + const queryKey = useMemo( + () => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const, + [selectedHost.serverId, selectedHost.repoRoot], + ); + + const readQuery = useQuery({ + queryKey, + queryFn: () => client.readProjectConfig(selectedHost.repoRoot), + retry: false, + }); + + const data = readQuery.data; + const projectIconTargets = useMemo( + () => [ + { + serverId: selectedHost.serverId, + projectKey: project.projectKey, + iconWorkingDir: selectedHost.repoRoot, + }, + ], + [project.projectKey, selectedHost.repoRoot, selectedHost.serverId], + ); + const projectIconDataByKey = useProjectIconDataByProjectKey({ + projects: projectIconTargets, + }); + const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null; + const loadedConfig: OttoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; + const loadedRevision: OttoConfigRevision | null = data?.ok ? data.revision : null; + const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; + + const handleReload = useCallback(() => { + void readQuery.refetch(); + }, [readQuery]); + + const hasMultipleHosts = hosts.length > 1; + + return ( + + + + {saveState ? ( + + ) : null} + + + + + + + + + + + + + {renderContent({ + readQuery, + loadedConfig, + loadedRevision, + readError, + selectedHost, + queryKey, + client, + onReload: handleReload, + hasMultipleHosts, + isHostGone, + onSaveStateChange: setSaveState, + })} + + ); +} + +interface RenderContentInput { + readQuery: ReturnType>; + loadedConfig: OttoConfigRaw | null; + loadedRevision: OttoConfigRevision | null; + readError: ProjectConfigRpcError | null; + selectedHost: ProjectHostEntry; + queryKey: readonly [string, string, string]; + client: DaemonClient; + onReload: () => void; + hasMultipleHosts: boolean; + isHostGone: boolean; + onSaveStateChange: (state: ProjectFormSaveState | null) => void; +} + +function renderContent({ + readQuery, + loadedConfig, + loadedRevision, + readError, + selectedHost, + queryKey, + client, + onReload, + hasMultipleHosts, + isHostGone, + onSaveStateChange, +}: RenderContentInput) { + if (readQuery.isLoading) { + return ( + + + + ); + } + + if (readQuery.isError) { + return ( + + ); + } + + if (readError) { + return ( + + ); + } + + if (isHostGone) { + return ; + } + + if (!loadedConfig) { + return ( + + + + ); + } + + const formKey = `${selectedHost.serverId}::${selectedHost.repoRoot}::${revisionToKey(loadedRevision)}`; + return ( + + ); +} + +function revisionToKey(revision: OttoConfigRevision | null): string { + if (!revision) return "none"; + return `${revision.mtimeMs}-${revision.size}`; +} + +interface ReadFailureCalloutProps { + kind: "transport" | ProjectConfigRpcError["code"]; + error: unknown; + onReload: () => void; + hasMultipleHosts: boolean; +} + +function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) { + const { t } = useTranslation(); + const { testID, title, description } = resolveReadFailureCopy({ + kind, + error, + hasMultipleHosts, + t, + }); + return ( + + + + + + ); +} + +function resolveReadFailureCopy(input: { + kind: ReadFailureCalloutProps["kind"]; + error: unknown; + hasMultipleHosts: boolean; + t: TFunction; +}): { testID: string; title: string; description: string } { + if (input.kind === "invalid_project_config") { + return { + testID: "invalid-callout", + title: input.t("settings.project.readFailures.invalidTitle"), + description: input.t("settings.project.readFailures.invalidDescription"), + }; + } + if (input.kind === "project_not_found") { + return { + testID: "project-not-found-callout", + title: input.t("settings.project.readFailures.missingTitle"), + description: input.hasMultipleHosts + ? input.t("settings.project.readFailures.missingWithHosts") + : input.t("settings.project.readFailures.missingSingleHost"), + }; + } + if (input.kind === "transport") { + const detail = errorToDetail(input.error); + return { + testID: "read-transport-callout", + title: input.t("settings.project.readFailures.transportTitle"), + description: detail ?? input.t("settings.project.readFailures.transportFallback"), + }; + } + return { + testID: "read-failed-callout", + title: input.t("settings.project.readFailures.failedTitle"), + description: input.t("settings.project.readFailures.failedDescription"), + }; +} + +function errorToDetail(error: unknown): string | null { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return null; +} + +interface ProjectConfigFormProps { + baseConfig: OttoConfigRaw; + revision: OttoConfigRevision | null; + repoRoot: string; + queryKey: readonly [string, string, string]; + client: DaemonClient; + onReload: () => void; + onSaveStateChange: (state: ProjectFormSaveState | null) => void; +} + +// Content-only identity of a draft, for dirty comparison. Script row ids come +// from a module counter, so two configToDraft calls over the same config would +// differ by id alone — strip them. +function draftFingerprint(draft: ProjectConfigDraft): string { + return JSON.stringify({ + ...draft, + scripts: draft.scripts.map(({ id: _id, ...rest }) => rest), + }); +} + +function ProjectConfigForm({ + baseConfig, + revision, + repoRoot, + queryKey, + client, + onReload, + onSaveStateChange, +}: ProjectConfigFormProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const toast = useToast(); + const iconSize = useIconSize(); + const isDeveloperMode = useIsDeveloperMode(); + + const [draft, setDraft] = useState(() => configToDraft(baseConfig)); + const [initialFingerprint] = useState(() => draftFingerprint(draft)); + const [writeError, setWriteError] = useState(null); + const [editingScriptId, setEditingScriptId] = useState(null); + + const isDirty = useMemo( + () => draftFingerprint(draft) !== initialFingerprint, + [draft, initialFingerprint], + ); + + const saveMutation = useMutation({ + mutationFn: async (input: { + config: OttoConfigRaw; + expectedRevision: OttoConfigRevision | null; + }) => { + return client.writeProjectConfig({ + repoRoot, + config: input.config, + expectedRevision: input.expectedRevision, + }); + }, + onSuccess: (result) => { + if (result.ok) { + queryClient.setQueryData(queryKey, { + ok: true, + config: result.config, + revision: result.revision, + requestId: "local-cache", + repoRoot, + }); + setWriteError(null); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.show(t("settings.project.actions.saved"), { variant: "success" }); + } else { + setWriteError(result.error); + } + }, + }); + + const handleSave = useCallback(() => { + if (writeError?.code === "stale_project_config") return; + const config = applyDraftToConfig({ draft, base: baseConfig }); + saveMutation.mutate({ config, expectedRevision: revision }); + }, [draft, baseConfig, revision, writeError, saveMutation]); + + const handleReload = useCallback(() => { + setWriteError(null); + onReload(); + }, [onReload]); + + const updateDraft = useCallback((updater: (draft: ProjectConfigDraft) => ProjectConfigDraft) => { + setDraft((prev) => updater(prev)); + }, []); + + const handleSetupChange = useCallback( + (text: string) => updateDraft((d) => ({ ...d, setupText: text })), + [updateDraft], + ); + const handleTeardownChange = useCallback( + (text: string) => updateDraft((d) => ({ ...d, teardownText: text })), + [updateDraft], + ); + + const handleMetadataPromptChange = useCallback( + (key: MetadataPromptKey, text: string) => + updateDraft((d) => ({ + ...d, + metadataPrompts: { ...d.metadataPrompts, [key]: text }, + })), + [updateDraft], + ); + + const handleRemoveScript = useCallback( + async (script: ProjectScriptDraft) => { + const ok = await confirmDialog({ + title: t("settings.project.scripts.removeTitle"), + message: t("settings.project.scripts.removeMessage", { + name: script.name || t("settings.project.scripts.removeFallbackName"), + }), + confirmLabel: t("settings.project.scripts.actions.remove"), + cancelLabel: t("settings.project.actions.cancel"), + destructive: true, + }); + if (!ok) return; + updateDraft((d) => ({ + ...d, + scripts: d.scripts.filter((entry) => entry.id !== script.id), + })); + }, + [t, updateDraft], + ); + + const handleEditScript = useCallback((script: ProjectScriptDraft) => { + setEditingScriptId(script.id); + }, []); + + const handleAddScript = useCallback(() => { + const id = `script-draft-new-${Date.now()}`; + updateDraft((d) => ({ + ...d, + scripts: [ + ...d.scripts, + { + id, + name: "", + commandText: "", + commandOriginalKind: "missing" satisfies LifecycleOriginalKind, + type: "", + portText: "", + rawEntry: {}, + }, + ], + })); + setEditingScriptId(id); + }, [updateDraft]); + + const handleEditingDraftChange = useCallback( + (next: ProjectScriptDraft) => { + updateDraft((d) => ({ + ...d, + scripts: d.scripts.map((entry) => (entry.id === next.id ? next : entry)), + })); + }, + [updateDraft], + ); + + const handleCancelEditing = useCallback(() => { + if (!editingScriptId) { + return; + } + updateDraft((d) => { + const entry = d.scripts.find((row) => row.id === editingScriptId); + if (!entry) return d; + const isEmpty = + entry.name.trim().length === 0 && + entry.commandText.trim().length === 0 && + entry.type.trim().length === 0 && + entry.portText.trim().length === 0; + if (!isEmpty) return d; + return { ...d, scripts: d.scripts.filter((row) => row.id !== editingScriptId) }; + }); + setEditingScriptId(null); + }, [editingScriptId, updateDraft]); + + const handleSaveEditing = useCallback(() => { + setEditingScriptId(null); + }, []); + + const editingScript = draft.scripts.find((entry) => entry.id === editingScriptId); + + const hasInvalidScripts = useMemo( + () => draft.scripts.some((script) => validateScript(script, t).hasErrors), + [draft.scripts, t], + ); + + const scriptsTrailing = useMemo( + () => ( + + + + ), + [handleAddScript, t, iconSize.sm], + ); + + const setupDocsLink = useMemo( + () => ( + + ), + [t], + ); + const teardownDocsLink = useMemo( + () => ( + + ), + [t], + ); + + const isStale = writeError?.code === "stale_project_config"; + const isWriteFailed = writeError?.code === "write_failed"; + const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts || !isDirty; + + // Publish save state to the header button without re-rendering the whole + // body on every keystroke: the handler is ref-stable, so the effect only + // fires when one of the booleans flips. + const saveHandlerRef = useRef(handleSave); + useEffect(() => { + saveHandlerRef.current = handleSave; + }, [handleSave]); + const stableSave = useCallback(() => saveHandlerRef.current(), []); + + const isSaving = saveMutation.isPending; + useEffect(() => { + if (!isDeveloperMode) { + onSaveStateChange(null); + return; + } + onSaveStateChange({ + isDirty, + isSaving, + canSave: !saveDisabled, + save: stableSave, + }); + }, [onSaveStateChange, isDeveloperMode, isDirty, isSaving, saveDisabled, stableSave]); + + useEffect(() => () => onSaveStateChange(null), [onSaveStateChange]); + + return ( + + {isDeveloperMode ? ( + <> + + + + + + + + + + + + + {draft.scripts.length === 0 ? ( + + {t("settings.project.scripts.empty")} + + ) : ( + draft.scripts.map((script, index) => ( + + )) + )} + + + + + {METADATA_PROMPT_KEYS.map((key, index) => ( + + ))} + + + {isStale ? ( + + + + + + ) : null} + + {isWriteFailed ? ( + + + + + + + ) : null} + + {editingScript ? ( + + ) : null} + + ) : null} + + ); +} + +function ResolveSpinnerColor(): string { + return styles.spinnerColor.color; +} + +interface ProjectLinksSectionProps { + serverId: string; + projectId: string; + client: DaemonClient; +} + +interface LinkableProject { + projectId: string; + projectName: string; +} + +// The gated-multi-root link manager: link this project to others on the same +// host so their files can be opened/edited in place. Links are bidirectional — +// linking here also links the other side. +function ProjectLinksSection({ serverId, projectId, client }: ProjectLinksSectionProps) { + const { t } = useTranslation(); + const toast = useToast(); + const queryClient = useQueryClient(); + const supported = useHostFeature(serverId, "projectLinks"); + const { linkSet } = useProjectLinkSet(serverId); + const workspacesMap = useSessionStore((state) => state.sessions[serverId]?.workspaces ?? null); + + const others = useMemo(() => { + if (!workspacesMap) { + return []; + } + const byId = new Map(); + for (const descriptor of workspacesMap.values()) { + if (descriptor.projectId === projectId) { + continue; + } + byId.set(descriptor.projectId, descriptor.projectCustomName ?? descriptor.projectDisplayName); + } + return Array.from(byId, ([id, projectName]) => ({ projectId: id, projectName })).sort((a, b) => + a.projectName.localeCompare(b.projectName), + ); + }, [workspacesMap, projectId]); + + const mutation = useMutation({ + mutationFn: async (input: { otherProjectId: string; link: boolean }) => { + if (input.link) { + await client.linkProjects(projectId, input.otherProjectId); + } else { + await client.unlinkProjects(projectId, input.otherProjectId); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectLinksQueryKey(serverId) }); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : t("settings.project.links.error"); + toast.error(message); + }, + }); + + const handleToggle = useCallback( + (otherProjectId: string, link: boolean) => mutation.mutate({ otherProjectId, link }), + [mutation], + ); + + if (!supported) { + return null; + } + + return ( + + + {others.length === 0 ? ( + + {t("settings.project.links.empty")} + + ) : ( + others.map((other, index) => ( + + )) + )} + + + ); +} + +function ProjectLinkRow({ + project, + isFirst, + linked, + disabled, + onToggle, +}: { + project: LinkableProject; + isFirst: boolean; + linked: boolean; + disabled: boolean; + onToggle: (otherProjectId: string, link: boolean) => void; +}) { + const { t } = useTranslation(); + const handleValueChange = useCallback( + (link: boolean) => onToggle(project.projectId, link), + [onToggle, project.projectId], + ); + return ( + + + + {project.projectName} + + + + + ); +} + +interface ProjectNameEditorProps { + project: ProjectSummary; + client: DaemonClient; +} + +function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const toast = useToast(); + const iconSize = useIconSize(); + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(project.projectCustomName ?? ""); + + const renameMutation = useMutation({ + mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + setIsEditing(false); + toast.show(t("settings.project.rename.renamedToast"), { variant: "success" }); + }, + onError: (error) => { + const message = + error instanceof Error ? error.message : t("settings.project.rename.errorFallback"); + toast.show(message, { variant: "error" }); + }, + }); + + const handleStartEdit = useCallback(() => { + setValue(project.projectCustomName ?? ""); + setIsEditing(true); + }, [project.projectCustomName]); + + const handleCancel = useCallback(() => { + setIsEditing(false); + setValue(project.projectCustomName ?? ""); + }, [project.projectCustomName]); + + const handleSave = useCallback(() => { + const trimmed = value.trim(); + const next = trimmed.length === 0 ? null : trimmed; + if (next === (project.projectCustomName ?? null)) { + setIsEditing(false); + return; + } + renameMutation.mutate(next); + }, [value, project.projectCustomName, renameMutation]); + + const handleReset = useCallback(() => { + renameMutation.mutate(null); + }, [renameMutation]); + + if (!isEditing) { + return ( + + + {project.projectName} + + + + + {project.projectCustomName ? ( + + {t("settings.project.rename.reset")} + + ) : null} + + ); + } + + return ( + + + + + + + + + + ); +} + +function ProjectTitleIcon({ + iconDataUri, + projectName, + projectKey, +}: { + iconDataUri: string | null; + projectName: string; + projectKey: string; +}) { + const initial = projectName.trim().charAt(0).toUpperCase() || "?"; + return ( + + ); +} + +interface HostContextProps { + hosts: ProjectHostEntry[]; + selectedHost: ProjectHostEntry; + onSelectHost: (serverId: string) => void; +} + +function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) { + if (hosts.length > 1) { + return ; + } + return ( + + + + {selectedHost.serverName} + + + ); +} + +interface HostPickerProps { + hosts: ProjectHostEntry[]; + selectedHost: ProjectHostEntry; + onSelectHost: (serverId: string) => void; +} + +function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) { + const { t } = useTranslation(); + const iconSize = useIconSize(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const hostOptions = useMemo( + () => hosts.map((host) => ({ serverId: host.serverId, label: host.serverName })), + [hosts], + ); + const handleOpen = useCallback(() => setOpen(true), []); + const hostOptionTestID = useCallback((serverId: string) => `host-picker-item-${serverId}`, []); + return ( + + + + + {selectedHost.serverName} + + + + + ); +} + +interface MetadataPromptSectionProps { + promptKey: MetadataPromptKey; + value: string; + onChange: (key: MetadataPromptKey, text: string) => void; + flush?: boolean; +} + +function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPromptSectionProps) { + const { t } = useTranslation(); + const meta = METADATA_PROMPT_FIELDS[promptKey]; + const title = t(meta.titleKey); + const handleChange = useCallback( + (text: string) => onChange(promptKey, text), + [onChange, promptKey], + ); + return ( + + + + ); +} + +interface ScriptRowProps { + script: ProjectScriptDraft; + isFirst: boolean; + onEdit: (script: ProjectScriptDraft) => void; + onRemove: (script: ProjectScriptDraft) => void; +} + +function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) { + const { t } = useTranslation(); + const iconSize = useIconSize(); + const handleEdit = useCallback(() => onEdit(script), [onEdit, script]); + const handleRemove = useCallback(() => onRemove(script), [onRemove, script]); + const rowStyle = isFirst ? styles.scriptRow : styles.scriptRowWithBorder; + + return ( + + + + {script.name || t("settings.project.scripts.untitled")} + + + {scriptHint(script, t)} + + + + + + + + + {t("settings.project.scripts.actions.edit")} + + + {t("settings.project.scripts.actions.remove")} + + + + + ); +} + +function scriptHint(script: ProjectScriptDraft, t: TFunction): string { + const pieces: string[] = []; + if (script.type) pieces.push(script.type); + if (script.portText) pieces.push(t("settings.project.scripts.port", { port: script.portText })); + if (script.commandText) pieces.push(script.commandText.split("\n")[0] ?? ""); + return pieces.join(" · "); +} + +interface ScriptValidation { + hasErrors: boolean; + nameError: string | null; + commandError: string | null; +} + +function validateScript(script: ProjectScriptDraft, t: TFunction): ScriptValidation { + const nameError = + script.name.trim().length === 0 ? t("settings.project.scripts.nameRequired") : null; + const commandError = + script.commandText.trim().length === 0 ? t("settings.project.scripts.commandRequired") : null; + return { + hasErrors: Boolean(nameError || commandError), + nameError, + commandError, + }; +} + +interface ScriptEditModalProps { + script: ProjectScriptDraft; + onChange: (next: ProjectScriptDraft) => void; + onCancel: () => void; + onSave: () => void; +} + +interface ScriptFieldsTouched { + name: boolean; + command: boolean; +} + +const ALL_TOUCHED: ScriptFieldsTouched = { name: true, command: true }; +const NONE_TOUCHED: ScriptFieldsTouched = { name: false, command: false }; + +function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModalProps) { + const { t } = useTranslation(); + const [touched, setTouched] = useState(NONE_TOUCHED); + + useEffect(() => { + setTouched(NONE_TOUCHED); + }, [script.id]); + + const markTouched = useCallback((field: keyof ScriptFieldsTouched) => { + setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true })); + }, []); + + const handleNameChange = useCallback( + (text: string) => onChange({ ...script, name: text }), + [onChange, script], + ); + const handleCommandChange = useCallback( + (text: string) => onChange({ ...script, commandText: text }), + [onChange, script], + ); + const handleServiceToggle = useCallback( + (next: boolean) => onChange({ ...script, type: next ? SCRIPT_SERVICE_TYPE : "" }), + [onChange, script], + ); + + const handleNameBlur = useCallback(() => markTouched("name"), [markTouched]); + const handleCommandBlur = useCallback(() => markTouched("command"), [markTouched]); + + const validation = validateScript(script, t); + + const handleSavePress = useCallback(() => { + if (validation.hasErrors) { + setTouched(ALL_TOUCHED); + return; + } + onSave(); + }, [validation.hasErrors, onSave]); + + const showNameError = touched.name && validation.nameError; + const showCommandError = touched.command && validation.commandError; + const isService = script.type === SCRIPT_SERVICE_TYPE; + const sheetHeader = useMemo( + () => ({ + title: script.name + ? t("settings.project.scripts.editScript", { name: script.name }) + : t("settings.project.scripts.newScript"), + }), + [script.name, t], + ); + + const footer = useMemo( + () => ( + + + + + ), + [handleSavePress, onCancel, t], + ); + + return ( + + + {t("settings.project.scripts.name")} + + {showNameError ? ( + + {validation.nameError} + + ) : null} + + + {t("settings.project.scripts.command")} +