diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff145fda..3f853477 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,8 +65,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Bundle CodeMirror - run: npm run bundle:codemirror + - name: Bundle editor and file previews + run: npm run bundle - name: Build run: npx electron-builder --${{ matrix.platform }} --publish never diff --git a/.gitignore b/.gitignore index f3f7f916..8ab697c3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ dist/ *.db-wal *.db-shm public/codemirror-bundle.js +public/pptx-preview-bundle.js +public/pptx-preview-bundle.js.LEGAL.txt +public/jsonc-parser-bundle.js .DS_Store .idea/ *.pem diff --git a/README.md b/README.md index e7eef64b..97a40e7c 100644 --- a/README.md +++ b/README.md @@ -2,70 +2,220 @@ Your command center for CLI coding sessions. -Switchboard is a desktop app that gives you a unified view of all your coding agent sessions across every project. Launch, resume, fork, and monitor sessions from a single window — no more juggling terminal tabs or digging through `~/.claude/projects` and `~/.codex/sessions` to find that one conversation from last week. +Switchboard is a desktop app that puts every Claude Code and Codex session, across every project, in one window. Launch, resume, fork, and monitor sessions without juggling terminal tabs or digging through `~/.claude/projects` and `~/.codex/sessions` for that one conversation from last week. ![Switchboard](build/screenshot.png) -### Supported CLIs +**[Download the latest release](https://github.com/doctly/switchboard/releases/latest)** · **[Join the Slack community](https://join.slack.com/t/doctly-ai/shared_invite/zt-4948uf70i-_WeI229ehttWfvjWvGxl3Q)** + +## Contents + +- [Install](#install) +- [Supported CLIs](#supported-clis) +- [Features at a glance](#features-at-a-glance) +- [Sessions](#sessions) +- [Projects](#projects) +- [Project Tasks and Server Logs](#project-tasks-and-server-logs) +- [Keyboard shortcuts](#keyboard-shortcuts) +- [Development](#development) +- [Further reading](#further-reading) + +## Install + +Grab the release for your platform from the [releases page](https://github.com/doctly/switchboard/releases/latest): + +| Platform | Package | +|---|---| +| macOS | `.dmg` (Apple Silicon and Intel) | +| Windows | `.exe` installer | +| Linux | `.AppImage`, `.deb`, or `.pacman` (Arch/Manjaro) | + +On Arch the package is named **`switchboard-doctly`**. The app is still called Switchboard everywhere you see it. Uninstall with `sudo pacman -R switchboard-doctly`. + +Packaged builds check GitHub Releases for updates on launch and every 4 hours, download in the background, and show a toast when an update is ready. Restart to install immediately, or dismiss and it installs on next quit. To build from source instead, see [Development](#development). + +## Supported CLIs | | Claude Code (`claude`) | Codex (`codex`) | |---|---|---| -| Browse & search history | ✅ | ✅ | +| Browse and search history | ✅ | ✅ | | Resume a session | ✅ | ✅ | | Start a new session | ✅ | ✅ | | Fork a session | ✅ | ✅ | | Read in the message viewer | ✅ | ✅ | -| Status & activity indicators | ✅ | ✅ | +| Status and activity indicators | ✅ | ✅ | +| Session names from `/rename` | ✅ | — | | IDE emulation (diff review) | ✅ | — | -| Plans & memory files | ✅ | — | - -Sessions from both CLIs share one sidebar, each row marked with its own logo. A -fork runs on the CLI that wrote the session being forked. - -Turn either CLI off under **CLI Agents** in Settings. A switched-off CLI is not -scanned, not watched, not offered when you start a session, and its sessions are -hidden — but its history is kept, so switching it back on restores everything -straight away. At least one CLI always stays on. - -### Key Features - -- **Session Browser** — All your sessions from every supported CLI, organized by project, searchable by content -- **Built-in Terminal** — Connect to running sessions or launch new ones without leaving the app -- **Project Tasks & Server Logs** — Run `.vscode/tasks.json` commands from a compact project or worktree menu and view their live logs in the built-in terminal -- **Status Notifications** — In-app alerts when a session is waiting for permission approval or user input -- **Fork & Resume** — Branch off from any point in a session's history -- **Full-Text Search** — Find any session by what was discussed, not just when it happened -- **Per-CLI Launch Options** — Claude sessions offer permission mode, worktree and Chrome; Codex sessions offer sandbox policy, approval policy and model. Set them per session, per project, or globally -- **IDE Emulation** (Claude only) — Switchboard acts as an IDE for Claude CLI, showing file diffs and opens in a side panel where you can accept, reject, or edit changes before they're applied. Supports both inline and side-by-side diff views. Disable this in Global Settings if you prefer Claude to use your own editor (VS Code, Cursor, etc.) -- **Plans & Memory** (Claude only) — Browse and edit your plan files and CLAUDE.md memory in one place -- **Activity Stats** — Heatmap of your coding activity across all projects -- **Session Names** (Claude only) — Picks up session names from Claude Code's `/rename` command automatically +| Plans and memory files | ✅ | — | + +Sessions from both CLIs share one sidebar, each row marked with its CLI's logo. A fork runs on the CLI that wrote the session being forked. + +Turn either CLI off under **CLI Agents** in Settings. A switched-off CLI is not scanned, not watched, and not offered when you start a session, and its sessions are hidden. Its history is kept, so switching it back on restores everything immediately. At least one CLI always stays on. + +## Features at a glance + +- **Session browser** — Every session from every supported CLI, organized by folder, searchable by content. +- **Fork and resume** — Branch off from any point in a session's history. +- **Built-in terminal** — Connect to running sessions or launch new ones without leaving the app. +- **Status notifications** — In-app alerts when a session is waiting for permission approval or user input. +- **Session grid** — Live terminals for all open sessions on one screen. +- **Per-CLI launch options** — Permission mode, worktree, sandbox, approval policy, model, and effort, set per session, per project, or globally. +- **IDE emulation** (Claude only) — Review and edit Claude's proposed diffs in a side panel before they land. +- **Projects** — Group work by what it is, not where it lives: a folder on disk, a brief the agent reads, a plan, a todo list, and the sessions filed under it. +- **Project tasks and server logs** — Run `.vscode/tasks.json` commands and keep their logs beside your sessions. +- **Scheduled tasks** — Start a session with a prompt on a timer, in a project, a track, or any folder. +- **Plans and memory** (Claude only) — Browse and edit plan files and `CLAUDE.md` in one place. +- **Activity stats** — Heatmap of your coding activity across all projects. + +## Sessions + +The **Sessions** tab lists every session grouped by the folder it runs in. Full-text search finds a session by what was discussed, not just when it happened. Any session can be resumed, forked from any point in its history, or read in the message viewer. + +### Launch options + +Each CLI exposes its own options when you start or resume a session: + +| CLI | Options | +|---|---| +| Claude Code | Permission mode, worktree, Chrome, model, effort | +| Codex | Sandbox policy, approval policy, model, reasoning effort | + +Set them per session, per project, or globally in Settings. Model and effort are chosen per session or per schedule (Codex's model can also be set in Settings); left blank, each CLI uses its own default. + +The optional settings (model, effort, allowed tools, additional system prompt, pre-launch command, and additional directories) sit under **More options**, and show on their own once they have a value. Codex's reasoning effort is limited to what the chosen model supports, read from Codex's own model list: choosing a model that does not support the current effort sets it back to Default. + +### Scheduled tasks + +A scheduled task starts a session with a prompt on a timer: every 15 or 30 minutes, every hour, every day, on weekdays, or every week. Create one with **New scheduled task…** from a project's right-click menu, the **Schedules** button on a project's page, or the clock on a folder in the Sessions tab. Each task's menu shows when it runs next and can edit it, run it now, turn it off, or delete it. Sessions a schedule started are marked with a clock. + +Schedules use the same CLI-specific configuration form as **New Session → Configure**. Choose **Use folder defaults** to follow the current settings at each run, or **Customize** to save individual overrides. Each customized field can be reset to its folder default, and Claude and Codex choices are kept separately when switching CLIs. + +Legacy schedule imports keep permission mode, allowed tools, additional directories, appended system instructions, and the model, with the old runner's defaults saved explicitly. Budget is intentionally omitted, and effort is left unset. Successfully imported schedules are left alone on later scans. + +For Project View schedules, defaults come from the track's starting folder, then the project's default starting folder, then the Switchboard project folder. The schedule dialog shows the resolved folder. Attached project folders are supplied automatically; additional directories in the form are optional extras. + +### Status notifications + +Switchboard watches every session in the background, Claude and Codex alike, and shows status in the sidebar so you can tell at a glance which sessions need attention while you work in a different one. + +![Status Notifications](build/screenshot-notifications.png) + +- **Waiting for input** — A session that needs your response is highlighted. +- **Permission approval** — A session blocked on a permission grant or approval gets a badge immediately. Switchboard reads each CLI's own wording, so Claude's permission prompts and Codex's approval requests both register. +- **Activity indicators** — See which sessions are running, idle, or finished. + +### Session grid + +Toggle the grid from the sidebar for a bird's-eye view of all open sessions, grouped by project. + +![Session Grid Overview](build/screenshot-grid.png) + +- **Live terminals** — Every open session renders its full terminal in a card, whichever CLI it runs. +- **Status at a glance** — Each card shows a running/stopped/busy dot and last-activity time. +- **Click to focus, double-click to expand** — Click a card header to focus it. Double-click to return to the single-terminal view for that session. +- **Persistent** — The grid preference survives restarts. + +### IDE emulation and file preview (Claude only) + +Switchboard can act as an IDE for Claude Code. It speaks Claude CLI's own IDE protocol, so Codex sessions launch without it. When enabled, Claude's file opens and proposed edits appear in a side panel next to the terminal instead of going to an external editor. + +![IDE Emulation](build/screenshot-ide.png) + +- **Diff review** — A proposed change shows up as a diff. Accept or reject it in place. +- **Inline and side-by-side** — Toggle between unified and side-by-side views. The choice is remembered. +- **Partial acceptance** — In inline mode, accept or reject individual chunks, then submit the result. +- **File viewer** — Clickable file links in terminal output (OSC 8 hyperlinks) open in the side panel. Code gets syntax highlighting; images, PDFs, and PowerPoint decks open as previews; HTML opens in a sandboxed preview; and JSON and JSONC files open as a collapsible tree that keeps key order, exact numbers, and duplicate keys, with a jump to the line where a broken file goes wrong. + +To let Claude use VS Code, Cursor, or another editor instead, uncheck **IDE Emulation** in **Global Settings**. Switchboard then stops registering as an IDE and Claude CLI discovers your real editor. The change applies to new sessions only. + +## Projects + +The Sessions tab groups sessions by the folder they run in. The **Projects** tab groups them by the work they belong to. + +| | What it is | +|---|---| +| **Project** | A piece of work with a folder on disk. Everything else about it is optional. | +| **Track** | A line of effort inside a project, with its own sessions. Optional. | +| **Session** | A session as it is today, plus the project it belongs to. | +| **Folder** | What the Sessions tab shows: a path on disk. A project can attach any number. | + +![Project View overview](build/project-view-overview.png) + +### The project folder and brief + +Every project gets a real folder under `~/Switchboard/` (change it under **Projects Folder** in Global Settings). Switchboard writes a starter brief into `CLAUDE.md` and `AGENTS.md` there. The brief tells the agent where the project's files go, and the agent creates them when it first needs them: + +| File | Purpose | +|---|---| +| `plan.md` | The plan, in whatever form fits the work | +| `plan-tracker.md` | Phases with checkboxes, so Switchboard can show progress | +| `todos.md` | Follow-ups and ideas | +| `memory.md` | Anything worth remembering across sessions | + +None of these is read at the start of a session, only when the plan or the todos come up. The brief also lists the attached folders and tells the agent to read each folder's own instructions before changing files there. Attaching or detaching a folder updates that list. The rest of the brief is yours to edit. + +### Where sessions start + +Sessions start in the project folder by default. A project or a track can choose an attached folder instead. Wherever a project session starts, Switchboard passes the project folder and every attached folder to the CLI as extra directories, so the agent can read and edit all of them. + +The two CLIs handle this differently: + +- **Claude** loads the `CLAUDE.md` from each of those directories. +- **Codex** only reads `AGENTS.md` from the folder it starts in, so a Codex session that starts in an attached folder does not see the project brief. Codex also refuses extra directories unless its sandbox is `workspace-write` or `danger-full-access`, so Switchboard's global default for Codex is `workspace-write`. Choose "Default" in Settings to hand the choice back to Codex's own config. Under `read-only` the extra directories are left off, since read-only can read every path anyway. + +### Overview and working mode + +The Projects tab lists projects only. Selecting one opens its **Overview**: the plan's progress, open todos, the newest files in the project folder, attached folders, files you've dropped in, and one card per track with its latest sessions. A file opens in the **Files** tab, which browses the whole project folder in the same viewer. + +Opening a session from there switches to working mode: a slim project strip on top, a session list beside the terminal, and the plan and todo counts at the foot of the list. + +![Project View session workspace](build/project-view-session.png) + +- **Grouping** — Group the list by **Time** (today, yesterday, this week...), **Track**, or **State** (needs input, running, idle). +- **Rows** — Each row shows the title, the track, the CLI, its age, and message count. Right-click a project, track, or session for its actions. +- **Ordering** — Projects and sessions are ordered by their last event: a new session started, a turn finished, or the CLI asked for something. Opening or resuming a session does not move it, so the list holds still under a click. A session that is still working does not move while its transcript grows, so two working sessions hold their places instead of leapfrogging. +- **Archived** — Archived sessions sit in a closed "Archived" line at the foot of the list and under each track card. One click opens them, dimmed, in place. + +The project's **Settings** tab is a list of rows: the name, the start folder, folders, the worktree branch, and the tracks. Changes save as you make them. Folder rows show the branch each folder is on, read from git when the page opens, and "modified" when there are uncommitted changes. + +### Creating and organizing projects + +![Project View new session menu](build/project-view-new-session-menu.png) + +- **New project** — Name it, pick a template, and attach the folders it works in. The dialog shows what Create will make: the project folder and its files, each worktree on its branch, and the tracks the template adds. Sessions started from the overview are filed under the project and still show under their folder in the Sessions tab. +- **Tracks** — Optional lines of work inside a project, each with its own sessions, start folder, and CLI. A track card's "Resume latest" reopens its most recent session. "New" starts one there. +- **Move to project** — Any session row has a move action. Nothing is filed until you launch it from a project or move it there. +- **Mark as done** — Done projects drop to the bottom, collapsed. Removing a project only forgets it. The folder and the sessions stay on disk. +- **Snooze** — Right-click a project and choose **Snooze**: in 1 hour, in 3 hours, this evening, tomorrow, next week, or a time you pick. It moves to a collapsed **Snoozed** section below Active, and its sessions keep running. It comes back at that time, or sooner if a session needs your input, marked with a violet dot until you open it. **Wake now** brings it back early. A project waiting on your input can't be snoozed. + +![Project View snooze](build/project-view-snooze.png) + +### Plan tab + +![Project View plan and todos](build/project-view-plan.png) + +The Plan tab shows the phases in `plan-tracker.md` with their items, the todos, and which sessions started or finished each one. Tick items in place, or start a session on a phase or a todo: it opens with that item as its first prompt, filed under the project. A plan written in Claude Code's plan mode can be adopted into a project from the Plans list. + +### Worktrees + +Attaching a plain folder uses it where it is. Attaching a git repository asks how the project should work in it: + +- **As it is**, on whatever branch is checked out. +- **With its own checkout** under the project folder at `repos/`, on one branch shared by every worktree in the project, or one you name per repository. + +A worktree inherits the repository's `.vscode/tasks.json`. Switchboard keeps its generated `CLAUDE.md` and `AGENTS.md` in the project folder; it does not add instruction overrides or ignore rules to attached repositories. Marking a project done offers to remove its worktrees. Branches stay. ## Project Tasks and Server Logs -Switchboard can run the commands in a project's `.vscode/tasks.json` without -requiring a debugger. Use it for development servers, workers, asset watchers, -test suites, or any other long-running project command whose output you want to -keep beside your coding sessions. - -- **Compact task launcher** — A play button appears in each project header. In - worktree headers it appears between the hide and new-session buttons when you - hover, and stays visible while a task is running. -- **Live, retained logs** — Selecting a task opens its terminal in the main - pane. Output is retained when you switch to another session or task, and the - selected task view is restored after a renderer reload. -- **Independent lifecycle** — Tasks run separately from Claude and Codex - sessions. Stop or restart them from the terminal header without altering an - agent transcript. -- **Project and worktree scope** — Commands run with the selected project or - worktree as `${workspaceFolder}`. A worktree inherits its parent project's - tasks when it has no `.vscode/tasks.json` of its own; a worktree-local file - overrides the inherited one. -- **Compound stacks** — `dependsOn` tasks can start several services in - parallel, making one-click API + worker + frontend stacks possible. Stopping - the compound stops its child tasks. - -For example: +Switchboard runs the commands in a project's `.vscode/tasks.json` without needing a debugger. Use it for dev servers, workers, asset watchers, test suites, or any long-running command whose output you want next to your coding sessions. + +- **Task launcher** — A play button appears in each project header. In worktree headers it sits between the hide and new-session buttons on hover, and stays visible while a task runs. A project's menu combines the tasks from every attached folder. +- **Live, retained logs** — Running a task keeps the menu open and shows its state on the row. Clicking a task that has run opens its terminal in the main pane, or beside the session list inside a project. Output is kept when you switch away, and the selected task view is restored after a renderer reload. +- **Independent lifecycle** — Tasks run separately from Claude and Codex sessions. Stop or restart them from the terminal header without touching an agent transcript. +- **Project and worktree scope** — Commands run with the selected project or worktree as `${workspaceFolder}`. A worktree inherits its parent project's tasks when it has no `.vscode/tasks.json` of its own. A worktree-local file overrides the inherited one. +- **Compound stacks** — `dependsOn` tasks start several services in parallel, so one click can bring up API + worker + frontend. Stopping the compound stops its children. + +Example: ```jsonc { @@ -94,169 +244,117 @@ For example: } ``` -Switchboard reads JSON with comments and trailing commas and currently supports -`shell`, `process`, and `npm` tasks, `dependsOn`/`dependsOrder`, task working -directories and environment variables, platform overrides, and an optional -`options.envFile`. Common workspace, home, path-separator, and `${env:NAME}` -variables are expanded. Debug adapters, breakpoints, VS Code command/input -variables, and problem-matcher diagnostics are outside the current scope; the -feature is intentionally focused on running commands and seeing their logs. - -## Session Grid Overview - -Toggle the grid overview from the sidebar for a bird's-eye view of all your open sessions at once, grouped by project. - -![Session Grid Overview](build/screenshot-grid.png) - -- **Live terminals** — Every open session renders its full terminal in a card, so you can monitor multiple agents simultaneously, whichever CLI each one is running. -- **Status at a glance** — Each card shows a running/stopped/busy indicator dot and last-activity timestamp. -- **Click to focus, double-click to expand** — Click a card header to focus it; double-click to switch back to single-terminal view for that session. -- **Persistent** — Grid preference is saved across restarts. - -## File Preview Side Panel & Claude IDE MCP Emulator +**Supported:** JSON with comments and trailing commas; `shell`, `process`, and `npm` tasks; `dependsOn` and `dependsOrder`; task working directories and environment variables; platform overrides; an optional `options.envFile`; and the common workspace, home, path-separator, and `${env:NAME}` variables. -Switchboard can act as an IDE for your Claude Code sessions. This one is Claude-only — it speaks Claude CLI's own IDE protocol, and Codex sessions are launched without it. When enabled, Claude's file opens and proposed edits appear in a side panel next to the terminal instead of being sent to an external editor. +**Out of scope:** debug adapters, breakpoints, VS Code command and input variables, and problem-matcher diagnostics. The feature is deliberately focused on running commands and seeing their logs. -![IDE Emulation](build/screenshot-ide.png) - -- **Diff review** — When Claude proposes a file change, it shows up as a diff in the side panel. You can review the changes and accept or reject them directly. -- **Inline & side-by-side** — Toggle between inline (unified) and side-by-side diff views. Your preference is remembered across sessions. -- **Partial acceptance** — In inline mode, you can accept or reject individual chunks within a diff, then submit the final result. -- **File viewer** — Clickable file links in terminal output (OSC 8 hyperlinks) open in the side panel with syntax highlighting. - -To disable IDE emulation entirely (e.g. if you want Claude to use VS Code or Cursor instead), uncheck **IDE Emulation** in **Global Settings**. This stops Switchboard from registering as an IDE, so Claude CLI will discover and connect to your real editor. Changes take effect on new sessions — running sessions are not affected. - -## Status Notifications - -Switchboard monitors all your sessions in the background — Claude and Codex alike — and shows status indicators in the sidebar so you can tell at a glance which sessions need attention, even when you're working in a different one. - -![Status Notifications](build/screenshot-notifications.png) - -- **Waiting for input** — A session that needs your response is highlighted so you don't miss it. -- **Permission approval** — When a session is blocked waiting for a permission grant or an approval, its badge lets you know immediately. Switchboard reads each CLI's own wording, so Claude's permission prompts and Codex's approval requests both register. -- **Activity indicators** — See which sessions are actively running, idle, or finished. - -## Editor +## Keyboard shortcuts | Shortcut | Action | -|----------|--------| -| `Cmd+F` / `Ctrl+F` | Find in file (also works in terminal) | +|---|---| +| `Cmd+F` / `Ctrl+F` | Find in file (also works in the terminal) | | `Cmd+G` / `Ctrl+G` | Go to line | -## Download - -Grab the latest release for your platform: - -**[Download Switchboard](https://github.com/doctly/switchboard/releases/latest)** +## Development -- **macOS**: `.dmg` (Apple Silicon & Intel) -- **Windows**: `.exe` installer -- **Linux**: `.AppImage`, `.deb`, or `.pacman` (Arch/Manjaro) - -## Prerequisites +### Prerequisites - **Node.js** 20+ - **npm** 10+ -- Platform build tools for native modules: +- Build tools for native modules (node-pty, better-sqlite3): - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - - **Linux**: `build-essential`, `python3` (`sudo apt install build-essential python3`) + - **Linux**: `build-essential` and `python3` (`sudo apt install build-essential python3`) - **Windows**: Visual Studio Build Tools or `npm install -g windows-build-tools` -## Development Setup +### Running from source ```bash -# Install dependencies (runs postinstall automatically) -npm install - -# Start the app -npm start +npm install # installs dependencies and runs postinstall +npm start # bundles CodeMirror, then launches Electron ``` -`npm start` bundles CodeMirror and launches Electron. For faster iteration after the first run: +After the first run, skip the bundle step for faster iteration: ```bash npm run electron ``` -## Building - -All build commands bundle CodeMirror first, then invoke electron-builder. +To keep a development instance's data separate from the installed app, set `SWITCHBOARD_DATA_DIR`: ```bash -# Current platform -npm run build - -# Platform-specific -npm run build:mac # DMG + zip (arm64 + x64) -npm run build:win # NSIS installer (x64 + arm64) -npm run build:linux # AppImage + deb + pacman (x64 + arm64) +npm run electron-dev # uses ~/.switchboard-dev ``` -Output goes to `dist/`. - -### Building on Arch / Manjaro - -The `deb` and `pacman` targets are built via the `fpm` binary bundled by -electron-builder, which links against `libcrypt.so.1`. Arch ships `libxcrypt` -without that legacy ABI, so install the compat shim once: +Run the test suite with: ```bash -sudo pacman -S libxcrypt-compat +npm test ``` -`AppImage` builds without it. +### Building -The pacman package is published as **`switchboard-doctly`** rather than -`switchboard` because the Arch `extra` repo already ships a package named -`switchboard` (elementary OS's Pantheon Control Center). Renaming avoids the -file-conflict that would block installation alongside it. The app itself is -still called Switchboard everywhere users see it — only the package identity -changes. Uninstall later with `sudo pacman -R switchboard-doctly`. - -## Releasing - -Releases are driven by git tags: +Every build command bundles CodeMirror first, then runs electron-builder. Output goes to `dist/`. ```bash -git tag v0.1.0 -git push origin v0.1.0 +npm run build # current platform +npm run build:mac # DMG + zip (arm64 + x64) +npm run build:win # NSIS installer (x64 + arm64) +npm run build:linux # AppImage + deb + pacman (x64 + arm64) ``` -The GitHub Actions workflow builds for all platforms and publishes to GitHub Releases. You can also release locally: +**Arch / Manjaro:** the `deb` and `pacman` targets use the `fpm` binary bundled with electron-builder, which links against `libcrypt.so.1`. Arch ships `libxcrypt` without that legacy ABI, so install the compat shim once. `AppImage` builds without it. ```bash -npm run release # builds + publishes to GitHub Releases +sudo pacman -S libxcrypt-compat ``` -Set `GH_TOKEN` in your environment (a GitHub personal access token with `repo` scope). +### Code signing -## Auto-Updates +Set these environment variables for signed distribution builds: -The app uses `electron-updater` to check for updates from GitHub Releases on launch and every 4 hours. Updates are only checked in packaged builds (not during development). The flow: +| Platform | Variables | +|---|---| +| macOS | `CSC_LINK` (p12 certificate) and `CSC_KEY_PASSWORD`, or sign via Keychain | +| Windows | `CSC_LINK` and `CSC_KEY_PASSWORD` for EV/OV code signing | +| Any | `CSC_IDENTITY_AUTO_DISCOVERY=false` to skip signing (CI artifact builds) | -1. App auto-downloads updates in the background -2. A toast notification appears when the update is ready -3. User can restart immediately or dismiss (installs on next quit) +The macOS build uses custom entitlements in `build/entitlements.mac.plist` to allow JIT and unsigned memory execution, which the native modules require. -## Code Signing +### Releasing -For distribution, set these environment variables: +Releases are driven by git tags. The GitHub Actions workflow builds for all platforms and publishes to GitHub Releases: -- **macOS**: `CSC_LINK` (p12 certificate) and `CSC_KEY_PASSWORD`, or sign via Keychain -- **Windows**: `CSC_LINK` and `CSC_KEY_PASSWORD` for EV/OV code signing -- Set `CSC_IDENTITY_AUTO_DISCOVERY=false` to skip signing (CI artifact builds) +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` -The macOS build uses custom entitlements (`build/entitlements.mac.plist`) to allow JIT and unsigned memory execution, required by native modules (node-pty, better-sqlite3). +To release locally instead, set `GH_TOKEN` to a GitHub personal access token with `repo` scope and run: -## Project Structure +```bash +npm run release +``` + +### Project structure ``` main.js Electron main process preload.js Context bridge (IPC bindings) -db.js SQLite session cache & metadata -harnesses/ Per-CLI modules (claude, codex) + registry +db.js SQLite session cache and metadata +harnesses/ Per-CLI modules (claude, codex) and registry public/ Renderer (HTML/CSS/JS) -scripts/ Build & postinstall scripts -build/ Icons, entitlements, builder resources +templates/ Built-in project templates (feature, research, customer) +workers/ Background workers +scripts/ Build and postinstall scripts +test/ Node test suite (npm test) +build/ Icons, entitlements, screenshots, builder resources .github/workflows/ CI/CD ``` + +## Further reading + +- [`docs/customizing-colors.md`](docs/customizing-colors.md) — changing the app's colors and applying a light theme (in French). + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/build/project-view-new-session-menu.png b/build/project-view-new-session-menu.png new file mode 100644 index 00000000..69810bc3 Binary files /dev/null and b/build/project-view-new-session-menu.png differ diff --git a/build/project-view-overview.png b/build/project-view-overview.png new file mode 100644 index 00000000..350ada57 Binary files /dev/null and b/build/project-view-overview.png differ diff --git a/build/project-view-plan.png b/build/project-view-plan.png new file mode 100644 index 00000000..926eca46 Binary files /dev/null and b/build/project-view-plan.png differ diff --git a/build/project-view-session.png b/build/project-view-session.png new file mode 100644 index 00000000..d7fdbd37 Binary files /dev/null and b/build/project-view-session.png differ diff --git a/build/project-view-snooze.png b/build/project-view-snooze.png new file mode 100644 index 00000000..218c4400 Binary files /dev/null and b/build/project-view-snooze.png differ diff --git a/claude-auth.js b/claude-auth.js index fa6d9e3f..dbe9c693 100644 --- a/claude-auth.js +++ b/claude-auth.js @@ -66,7 +66,6 @@ function formatResetTime(value) { } if (isNaN(resetDate.getTime())) return null; const now = new Date(); - const diffMs = resetDate - now; const hours = resetDate.getHours(); const minutes = resetDate.getMinutes(); @@ -77,8 +76,13 @@ function formatResetTime(value) { const tz = Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(resetDate) .find(p => p.type === 'timeZoneName')?.value || ''; - if (diffMs < 0) return `${timeStr} (${tz})`; - if (diffMs < 24 * 60 * 60 * 1000) return `${timeStr} (${tz})`; + // Which calendar day it lands on, not how far away it is: a reset 8 hours + // from 11pm is tomorrow, and a bare "7am" would read as one that has passed. + // Rounded, so a DST day of 23 or 25 hours still counts as one day. + const startOfDay = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x.getTime(); }; + const days = Math.round((startOfDay(resetDate) - startOfDay(now)) / 86400000); + if (days === 0) return `${timeStr} (${tz})`; + if (days === 1) return `tomorrow at ${timeStr} (${tz})`; const month = resetDate.toLocaleString('en', { month: 'short' }); const day = resetDate.getDate(); diff --git a/codex-auth.js b/codex-auth.js index 16bc030d..b217b629 100644 --- a/codex-auth.js +++ b/codex-auth.js @@ -46,7 +46,6 @@ function formatResetTime(value) { } if (isNaN(resetDate.getTime())) return null; const now = new Date(); - const diffMs = resetDate - now; const hours = resetDate.getHours(); const minutes = resetDate.getMinutes(); @@ -57,7 +56,13 @@ function formatResetTime(value) { const tz = Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(resetDate) .find(p => p.type === 'timeZoneName')?.value || ''; - if (diffMs < 24 * 60 * 60 * 1000) return `${timeStr} (${tz})`; + // Which calendar day it lands on, not how far away it is: a reset 8 hours + // from 11pm is tomorrow, and a bare "7am" would read as one that has passed. + // Rounded, so a DST day of 23 or 25 hours still counts as one day. + const startOfDay = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x.getTime(); }; + const days = Math.round((startOfDay(resetDate) - startOfDay(now)) / 86400000); + if (days === 0) return `${timeStr} (${tz})`; + if (days === 1) return `tomorrow at ${timeStr} (${tz})`; const month = resetDate.toLocaleString('en', { month: 'short' }); const day = resetDate.getDate(); diff --git a/db.js b/db.js index c8923682..59e23f83 100644 --- a/db.js +++ b/db.js @@ -182,6 +182,138 @@ if (migrations.length > currentDbVersion) { } } +// --- Projects --- +// A project is a piece of work with a folder on disk (`root`). It attaches +// zero or more folders (the cwds sessions run in) and, later, tracks. Sessions +// are assigned through session_meta.projectId / trackId. These live in their +// own tables, never under the `project:` settings key, because hiding a +// folder deletes that key (see remove-project in main.js). +// +// Same rule as above: tables and columns are ensured by inspecting the schema, +// not by db_version, so a DB touched by a parallel branch still gets them. +db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + root TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + sharedBranch INTEGER NOT NULL DEFAULT 1, + branchName TEXT, + created TEXT NOT NULL, + modified TEXT NOT NULL + ) +`); +db.exec(` + CREATE TABLE IF NOT EXISTS project_folders ( + projectId TEXT NOT NULL, + path TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'in-place', + sourcePath TEXT, + branch TEXT, + sortOrder INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (projectId, path) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_project_folders_path ON project_folders(path)'); +db.exec(` + CREATE TABLE IF NOT EXISTS tracks ( + id TEXT PRIMARY KEY, + projectId TEXT NOT NULL, + name TEXT NOT NULL, + cwd TEXT, + cli TEXT, + status TEXT NOT NULL DEFAULT 'active', + sortOrder INTEGER NOT NULL DEFAULT 0, + created TEXT NOT NULL + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_tracks_project ON tracks(projectId)'); +// A scheduled task: a saved prompt plus a time. It lives in exactly one place, +// decided by projectId — set, it is listed in the project view under trackId +// (null = General) and runs in the track's cwd; null, it is a folder schedule +// listed on the Sessions tab under `cwd`. Timing is stored as fields +// (`every`, atHour, atMinute, weekday); `every = 'cron'` keeps a raw cron +// string only for schedules imported from the old schedule-*.md files. +db.exec(` + CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + projectId TEXT, + trackId TEXT, + cwd TEXT, + prompt TEXT NOT NULL, + every TEXT NOT NULL, + atHour INTEGER, + atMinute INTEGER, + weekday INTEGER, + cron TEXT, + cli TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + catchUp INTEGER NOT NULL DEFAULT 0, + sourceFile TEXT, + lastRunAt TEXT, + lastSessionId TEXT, + created TEXT NOT NULL + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_schedules_project ON schedules(projectId)'); +{ + const cols = new Set(db.prepare('PRAGMA table_info(schedules)').all().map(c => c.name)); + if (!cols.has('sessionConfig')) db.exec('ALTER TABLE schedules ADD COLUMN sessionConfig TEXT'); +} +// Record successful imports separately from schedule rows: deleting an +// imported task must not make its source file eligible for import again. +db.exec(` + CREATE TABLE IF NOT EXISTS legacy_schedule_imports ( + sourceFile TEXT PRIMARY KEY, + importedAt TEXT NOT NULL + ) +`); +// Adopt rows from builds that only stored a global import-completed flag. +// That flag cannot tell us which files an incomplete scan missed. +db.exec(` + INSERT OR IGNORE INTO legacy_schedule_imports (sourceFile, importedAt) + SELECT sourceFile, created FROM schedules WHERE sourceFile IS NOT NULL AND sourceFile != '' +`); +{ + const cols = new Set(db.prepare('PRAGMA table_info(session_meta)').all().map(c => c.name)); + if (!cols.has('projectId')) db.exec('ALTER TABLE session_meta ADD COLUMN projectId TEXT'); + if (!cols.has('trackId')) db.exec('ALTER TABLE session_meta ADD COLUMN trackId TEXT'); + if (!cols.has('formerTrackName')) db.exec('ALTER TABLE session_meta ADD COLUMN formerTrackName TEXT'); + // Which schedule started the session, and when it fired. Read by the + // session row's clock chip; nothing else depends on it. + if (!cols.has('scheduleId')) db.exec('ALTER TABLE session_meta ADD COLUMN scheduleId TEXT'); + if (!cols.has('scheduledAt')) db.exec('ALTER TABLE session_meta ADD COLUMN scheduledAt TEXT'); +} +{ + // Where a project's sessions start by default (null = the project folder). + // Tracks inherit it unless they set their own cwd. + const cols = new Set(db.prepare('PRAGMA table_info(projects)').all().map(c => c.name)); + if (!cols.has('defaultCwd')) db.exec('ALTER TABLE projects ADD COLUMN defaultCwd TEXT'); + // Snooze is an overlay on an active project: the row keeps status 'active' + // and is hidden from the list while snoozedUntil is in the future. Nothing + // clears the columns at wake time; a past snoozedUntil simply no longer + // counts, so the renderer decides from the timestamp alone. + if (!cols.has('snoozedUntil')) db.exec('ALTER TABLE projects ADD COLUMN snoozedUntil TEXT'); + if (!cols.has('snoozedAt')) db.exec('ALTER TABLE projects ADD COLUMN snoozedAt TEXT'); +} +// Which session started or finished a plan phase or a todo. Items are matched +// by their text, not their line, so editing the file above an item does not +// orphan its history. +db.exec(` + CREATE TABLE IF NOT EXISTS plan_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + projectId TEXT NOT NULL, + file TEXT NOT NULL, + itemText TEXT NOT NULL, + sessionId TEXT NOT NULL, + kind TEXT NOT NULL, + at TEXT NOT NULL + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_plan_links_project ON plan_links(projectId)'); + // --- FTS5 full-text search --- db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( @@ -262,7 +394,8 @@ const stmts = { searchMapDeleteByType: db.prepare('DELETE FROM search_map WHERE type = ?'), searchInsertFts: db.prepare('INSERT OR REPLACE INTO search_fts(rowid, title, body) VALUES (?, ?, ?)'), searchInsertMap: db.prepare('INSERT OR REPLACE INTO search_map(id, type, folder) VALUES (?, ?, ?)'), - searchMapLookup: db.prepare('SELECT rowid FROM search_map WHERE id = ? AND type = ?'), + searchMapLookup: db.prepare('SELECT rowid, folder FROM search_map WHERE id = ? AND type = ?'), + searchContentMatches: db.prepare('SELECT 1 FROM search_fts WHERE rowid = ? AND title = ? AND body = ?'), searchUpdateTitle: db.prepare('UPDATE search_fts SET title = ? WHERE rowid = (SELECT rowid FROM search_map WHERE id = ? AND type = ?)'), searchDeleteByRowid: db.prepare('DELETE FROM search_fts WHERE rowid = ?'), searchMapDeleteByRowid: db.prepare('DELETE FROM search_map WHERE rowid = ?'), @@ -278,9 +411,79 @@ const stmts = { FROM search_fts JOIN search_map ON search_fts.rowid = search_map.rowid WHERE search_map.type = ? AND search_fts MATCH ? + AND (? IS NULL OR search_map.id IN (SELECT value FROM json_each(?))) ORDER BY rank LIMIT ? `), + searchSessionIds: db.prepare(` + SELECT search_map.id + FROM search_map + CROSS JOIN search_fts ON search_fts.rowid = search_map.rowid + WHERE search_map.type = 'session' + AND search_map.id IN (SELECT value FROM json_each(?)) + AND search_fts MATCH ? + `), + // Project statements + projectList: db.prepare('SELECT * FROM projects ORDER BY created'), + projectGet: db.prepare('SELECT * FROM projects WHERE id = ?'), + projectGetBySlug: db.prepare('SELECT * FROM projects WHERE slug = ?'), + projectInsert: db.prepare(` + INSERT INTO projects (id, name, slug, root, status, sharedBranch, branchName, created, modified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + projectDelete: db.prepare('DELETE FROM projects WHERE id = ?'), + projectFoldersList: db.prepare('SELECT * FROM project_folders WHERE projectId = ? ORDER BY sortOrder, path'), + projectFoldersListAll: db.prepare('SELECT * FROM project_folders ORDER BY projectId, sortOrder, path'), + projectFolderUpsert: db.prepare(` + INSERT INTO project_folders (projectId, path, mode, sourcePath, branch, sortOrder) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(projectId, path) DO UPDATE SET + mode = excluded.mode, sourcePath = excluded.sourcePath, + branch = excluded.branch, sortOrder = excluded.sortOrder + `), + projectFolderDelete: db.prepare('DELETE FROM project_folders WHERE projectId = ? AND path = ?'), + projectFoldersDeleteByProject: db.prepare('DELETE FROM project_folders WHERE projectId = ?'), + tracksList: db.prepare('SELECT * FROM tracks WHERE projectId = ? ORDER BY sortOrder, created'), + tracksListAll: db.prepare('SELECT * FROM tracks ORDER BY projectId, sortOrder, created'), + trackGet: db.prepare('SELECT * FROM tracks WHERE id = ?'), + trackInsert: db.prepare(` + INSERT INTO tracks (id, projectId, name, cwd, cli, status, sortOrder, created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), + trackDelete: db.prepare('DELETE FROM tracks WHERE id = ?'), + tracksDeleteByProject: db.prepare('DELETE FROM tracks WHERE projectId = ?'), + schedulesListAll: db.prepare('SELECT * FROM schedules ORDER BY created'), + schedulesListByProject: db.prepare('SELECT * FROM schedules WHERE projectId = ? ORDER BY created'), + scheduleGet: db.prepare('SELECT * FROM schedules WHERE id = ?'), + scheduleInsert: db.prepare(` + INSERT INTO schedules (id, name, projectId, trackId, cwd, prompt, every, atHour, atMinute, weekday, cron, cli, enabled, catchUp, sourceFile, lastRunAt, lastSessionId, created, sessionConfig) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + scheduleDelete: db.prepare('DELETE FROM schedules WHERE id = ?'), + scheduleImportList: db.prepare('SELECT sourceFile FROM legacy_schedule_imports'), + scheduleImportRecord: db.prepare('INSERT OR IGNORE INTO legacy_schedule_imports (sourceFile, importedAt) VALUES (?, ?)'), + schedulesDeleteByProject: db.prepare('DELETE FROM schedules WHERE projectId = ?'), + schedulesClearTrack: db.prepare('UPDATE schedules SET trackId = NULL WHERE trackId = ?'), + scheduleRekeySession: db.prepare('UPDATE schedules SET lastSessionId = ? WHERE lastSessionId = ?'), + scheduleLinkSet: db.prepare(` + INSERT INTO session_meta (sessionId, scheduleId, scheduledAt) VALUES (?, ?, ?) + ON CONFLICT(sessionId) DO UPDATE SET scheduleId = excluded.scheduleId, scheduledAt = excluded.scheduledAt + `), + scheduleLinkRekey: db.prepare('UPDATE session_meta SET scheduleId = ?, scheduledAt = ? WHERE sessionId = ?'), + scheduleLinkClear: db.prepare('UPDATE session_meta SET scheduleId = NULL, scheduledAt = NULL WHERE scheduleId = ?'), + // Session ↔ project assignment lives on session_meta so it survives cache + // rebuilds. name/starred/archived are left untouched by these statements. + assignmentSet: db.prepare(` + INSERT INTO session_meta (sessionId, projectId, trackId) VALUES (?, ?, ?) + ON CONFLICT(sessionId) DO UPDATE SET projectId = excluded.projectId, trackId = excluded.trackId + `), + assignmentClearProject: db.prepare('UPDATE session_meta SET projectId = NULL, trackId = NULL WHERE projectId = ?'), + assignmentClearTrack: db.prepare('UPDATE session_meta SET trackId = NULL WHERE trackId = ?'), + // Plan links + planLinkInsert: db.prepare('INSERT INTO plan_links (projectId, file, itemText, sessionId, kind, at) VALUES (?, ?, ?, ?, ?, ?)'), + planLinksByProject: db.prepare('SELECT * FROM plan_links WHERE projectId = ? ORDER BY at'), + planLinksDeleteByProject: db.prepare('DELETE FROM plan_links WHERE projectId = ?'), + planLinksRekey: db.prepare('UPDATE plan_links SET sessionId = ? WHERE sessionId = ?'), }; function getMeta(sessionId) { @@ -375,18 +578,27 @@ function setFolderMeta(folder, projectPath, indexMtimeMs) { const upsertSearchEntriesBatch = db.transaction((entries) => { for (const e of entries) { + const folder = e.folder || null; + const title = e.title || ''; + const body = e.body || ''; + const existing = stmts.searchMapLookup.get(e.id, e.type); + // Transcript mtime also changes for tool output and CLI bookkeeping. Keep + // the FTS row when its searchable content is identical, including across + // app restarts. Comparing the stored text needs no migration or rebuild. + if (existing && existing.folder === folder && + stmts.searchContentMatches.get(existing.rowid, title, body)) continue; + // Delete any existing FTS row for this (id, type) pair before inserting. // search_map uses INSERT OR REPLACE which deletes the old row and creates // a new one with a new rowid, but the orphaned FTS5 row keyed to the old // rowid would never be cleaned up — causing duplicate search results and // unbounded FTS table growth. - const existing = stmts.searchMapLookup.get(e.id, e.type); if (existing) { stmts.searchDeleteByRowid.run(existing.rowid); stmts.searchMapDeleteByRowid.run(existing.rowid); } - const result = stmts.searchInsertMap.run(e.id, e.type, e.folder || null); - stmts.searchInsertFts.run(result.lastInsertRowid, e.title || '', e.body || ''); + const result = stmts.searchInsertMap.run(e.id, e.type, folder); + stmts.searchInsertFts.run(result.lastInsertRowid, title, body); } }); @@ -415,19 +627,26 @@ function updateSearchTitle(id, type, title) { } catch {} } -function searchByType(type, query, limit = 50, titleOnly = false) { +function searchByType(type, query, limit = 50, titleOnly = false, sessionIds = null) { try { // Wrap in double quotes for exact substring matching with trigram tokenizer. // This prevents FTS5 from splitting on punctuation (e.g. "spec.md" → "spec" + "md") const escaped = '"' + query.replace(/"/g, '""') + '"'; // FTS5 column filter: prefix with "title:" to restrict match to title column const match = titleOnly ? 'title:' + escaped : escaped; - return stmts.searchQuery.all(type, match, limit); + const scope = sessionIds === null ? null : JSON.stringify(sessionIds); + return stmts.searchQuery.all(type, match, scope, scope, limit); } catch { return []; } } +function searchSessionIds(query, sessionIds) { + if (!query.trim() || !sessionIds.length) return []; + const match = '"' + query.replace(/"/g, '""') + '"'; + return stmts.searchSessionIds.all(JSON.stringify(sessionIds), match).map(row => row.id); +} + function isSearchIndexPopulated() { const row = db.prepare('SELECT COUNT(*) as cnt FROM search_map WHERE type = ?').get('session'); return row.cnt > 0; @@ -449,6 +668,246 @@ function deleteSetting(key) { stmts.settingsDelete.run(key); } +// --- Project functions --- + +const PROJECT_PATCH_KEYS = ['name', 'status', 'sharedBranch', 'branchName', 'defaultCwd', 'snoozedUntil', 'snoozedAt', 'modified']; +const TRACK_PATCH_KEYS = ['name', 'cwd', 'cli', 'status', 'sortOrder']; +const SCHEDULE_PATCH_KEYS = ['name', 'trackId', 'cwd', 'prompt', 'every', 'atHour', 'atMinute', 'weekday', 'cron', 'cli', 'enabled', 'catchUp', 'lastRunAt', 'lastSessionId', 'sessionConfig']; + +function listProjects() { + return stmts.projectList.all(); +} + +function getProject(id) { + return stmts.projectGet.get(id) || null; +} + +function getProjectBySlug(slug) { + return stmts.projectGetBySlug.get(slug) || null; +} + +function insertProject(row) { + stmts.projectInsert.run( + row.id, row.name, row.slug, row.root, + row.status || 'active', row.sharedBranch === false ? 0 : 1, row.branchName || null, + row.created, row.modified + ); +} + +// Only whitelisted columns can change; the patch is applied with one UPDATE so +// callers cannot rename `id`, `slug` or `root` by accident. +function updatePatch(table, allowed, id, patch) { + const sets = []; + const values = []; + for (const key of allowed) { + if (!(key in patch)) continue; + sets.push(`${key} = ?`); + let value = patch[key]; + if (key === 'sharedBranch') value = value ? 1 : 0; + values.push(value === undefined ? null : value); + } + if (!sets.length) return 0; + values.push(id); + return db.prepare(`UPDATE ${table} SET ${sets.join(', ')} WHERE id = ?`).run(...values).changes; +} + +function updateProject(id, patch) { + return updatePatch('projects', PROJECT_PATCH_KEYS, id, patch); +} + +const deleteProjectTx = db.transaction((id) => { + stmts.assignmentClearProject.run(id); + stmts.tracksDeleteByProject.run(id); + stmts.schedulesDeleteByProject.run(id); + stmts.projectFoldersDeleteByProject.run(id); + stmts.planLinksDeleteByProject.run(id); + stmts.projectDelete.run(id); +}); + +// --- Plan links --- + +function insertPlanLink(row) { + stmts.planLinkInsert.run(row.projectId, row.file, row.itemText, row.sessionId, row.kind, row.at || new Date().toISOString()); +} + +function listPlanLinks(projectId) { + return stmts.planLinksByProject.all(projectId); +} + +/** A session that started under a temporary id keeps its links once the real id is known. */ +function rekeyPlanLinks(fromId, toId) { + return stmts.planLinksRekey.run(toId, fromId).changes; +} + +function deleteProject(id) { + deleteProjectTx(id); +} + +function listProjectFolders(projectId) { + return stmts.projectFoldersList.all(projectId); +} + +function listAllProjectFolders() { + return stmts.projectFoldersListAll.all(); +} + +function upsertProjectFolder(row) { + stmts.projectFolderUpsert.run( + row.projectId, row.path, row.mode || 'in-place', + row.sourcePath || null, row.branch || null, row.sortOrder || 0 + ); +} + +function deleteProjectFolder(projectId, folderPath) { + stmts.projectFolderDelete.run(projectId, folderPath); +} + +function listTracks(projectId) { + return stmts.tracksList.all(projectId); +} + +function listAllTracks() { + return stmts.tracksListAll.all(); +} + +function getTrack(id) { + return stmts.trackGet.get(id) || null; +} + +function insertTrack(row) { + stmts.trackInsert.run( + row.id, row.projectId, row.name, row.cwd || null, row.cli || null, + row.status || 'active', row.sortOrder || 0, row.created + ); +} + +function updateTrack(id, patch) { + return updatePatch('tracks', TRACK_PATCH_KEYS, id, patch); +} + +const deleteTrackTx = db.transaction((id, archiveSessions) => { + const track = stmts.trackGet.get(id); + if (!track) return []; + const sessionIds = db.prepare('SELECT sessionId FROM session_meta WHERE trackId = ?').all(id).map(row => row.sessionId); + db.prepare(`UPDATE session_meta SET formerTrackName = ?, trackId = NULL, + archived = CASE WHEN ? THEN 1 ELSE archived END WHERE trackId = ?`).run(track.name, archiveSessions ? 1 : 0, id); + stmts.trackDelete.run(id); + // Its schedules stay in the project, under General, like its sessions. + stmts.schedulesClearTrack.run(id); + return sessionIds; +}); + +function deleteTrack(id, { archiveSessions = false } = {}) { + return deleteTrackTx(id, archiveSessions); +} + +// --- Schedules --- + +function scheduleFromRow(row) { + return row ? { ...row, sessionConfig: row.sessionConfig ? JSON.parse(row.sessionConfig) : {} } : null; +} + +function listSchedules() { + return stmts.schedulesListAll.all().map(scheduleFromRow); +} + +function listSchedulesByProject(projectId) { + return stmts.schedulesListByProject.all(projectId).map(scheduleFromRow); +} + +function getSchedule(id) { + return scheduleFromRow(stmts.scheduleGet.get(id)); +} + +function insertSchedule(row) { + stmts.scheduleInsert.run( + row.id, row.name, row.projectId || null, row.trackId || null, row.cwd || null, + row.prompt, row.every, + row.atHour ?? null, row.atMinute ?? null, row.weekday ?? null, row.cron || null, + row.cli || null, row.enabled === false ? 0 : 1, row.catchUp ? 1 : 0, + row.sourceFile || null, row.lastRunAt || null, row.lastSessionId || null, row.created, + JSON.stringify(row.sessionConfig || {}) + ); +} + +function getImportedScheduleFiles() { + return stmts.scheduleImportList.all().map(row => row.sourceFile); +} + +// Both writes commit together. A failed schedule insert leaves the file +// eligible for retry; a repeated scan cannot create a second schedule. +const importLegacySchedule = db.transaction((row) => { + if (!row.sourceFile) throw new Error('An imported schedule needs a source file'); + const recorded = stmts.scheduleImportRecord.run(row.sourceFile, row.created); + if (!recorded.changes) return false; + insertSchedule(row); + return true; +}); + +function updateSchedule(id, patch) { + const clean = { ...patch }; + if ('sessionConfig' in clean) clean.sessionConfig = JSON.stringify(clean.sessionConfig || {}); + if ('enabled' in clean) clean.enabled = clean.enabled ? 1 : 0; + if ('catchUp' in clean) clean.catchUp = clean.catchUp ? 1 : 0; + return updatePatch('schedules', SCHEDULE_PATCH_KEYS, id, clean); +} + +const deleteScheduleTx = db.transaction((id) => { + const row = stmts.scheduleGet.get(id); + if (row?.sourceFile) stmts.scheduleImportRecord.run(row.sourceFile, row.created); + stmts.scheduleLinkClear.run(id); + stmts.scheduleDelete.run(id); +}); + +function deleteSchedule(id) { + deleteScheduleTx(id); +} + +/** A session started by a schedule: the row remembers it, and the schedule remembers the run. */ +const recordScheduleRunTx = db.transaction((scheduleId, sessionId, at) => { + stmts.scheduleLinkSet.run(sessionId, scheduleId, at); + db.prepare('UPDATE schedules SET lastRunAt = ?, lastSessionId = ? WHERE id = ?').run(at, sessionId, scheduleId); +}); + +function recordScheduleRun(scheduleId, sessionId, at) { + recordScheduleRunTx(scheduleId, sessionId, at); +} + +// A session that started under a temporary id keeps its schedule link once +// the real id is known (codex). +const rekeyScheduleSessionTx = db.transaction((fromId, toId) => { + const row = stmts.get.get(fromId); + if (row?.scheduleId) { + stmts.scheduleLinkSet.run(toId, row.scheduleId, row.scheduledAt); + stmts.scheduleLinkRekey.run(null, null, fromId); + } + stmts.scheduleRekeySession.run(toId, fromId); +}); + +function rekeyScheduleSession(fromId, toId) { + rekeyScheduleSessionTx(fromId, toId); +} + +function setSessionAssignment(sessionId, projectId, trackId) { + stmts.assignmentSet.run(sessionId, projectId || null, trackId || null); +} + +// A fork inherits its parent's project and track. +function copySessionAssignment(fromId, toId) { + const row = stmts.get.get(fromId); + if (!row || (!row.projectId && !row.trackId)) return false; + stmts.assignmentSet.run(toId, row.projectId || null, row.trackId || null); + return true; +} + +// A Codex session runs under a temporary id until its transcript appears; the +// assignment recorded at launch has to follow it to the real id. +function moveSessionAssignment(fromId, toId) { + const copied = copySessionAssignment(fromId, toId); + if (copied) stmts.assignmentSet.run(fromId, null, null); + return copied; +} + function closeDb() { try { db.close(); } catch {} } @@ -460,7 +919,15 @@ module.exports = { deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, - searchByType, isSearchIndexPopulated, searchFtsRecreated, + searchByType, searchSessionIds, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, + listProjects, getProject, getProjectBySlug, insertProject, updateProject, deleteProject, + listProjectFolders, listAllProjectFolders, upsertProjectFolder, deleteProjectFolder, + listTracks, listAllTracks, getTrack, insertTrack, updateTrack, deleteTrack, + listSchedules, listSchedulesByProject, getSchedule, insertSchedule, updateSchedule, deleteSchedule, + getImportedScheduleFiles, importLegacySchedule, + recordScheduleRun, rekeyScheduleSession, + setSessionAssignment, copySessionAssignment, moveSessionAssignment, + insertPlanLink, listPlanLinks, rekeyPlanLinks, closeDb, }; diff --git a/docs/codex-support-design.md b/docs/codex-support-design.md deleted file mode 100644 index 17f3f836..00000000 --- a/docs/codex-support-design.md +++ /dev/null @@ -1,421 +0,0 @@ -# Multi-harness sessions: adding OpenAI Codex - -Design for letting a session be **Claude** or **Codex**, remembering which is -which, and resuming each with the right CLI. - -Everything in "Verified facts" below was tested against `codex-cli 0.149.1` -installed locally, not read from docs. - ---- - -## 1. Verified facts about Codex - -| Thing | Claude Code | Codex CLI | -|---|---|---| -| Transcript location | `~/.claude/projects//.jsonl` | `$CODEX_HOME/sessions/YYYY/MM/DD/rollout--.jsonl` | -| Grouping on disk | one dir per **project** | one dir per **date** (mixed projects) | -| Project path source | `cwd` on any line | `session_meta.payload.cwd` on line 1 | -| Session id | the file name | the file name's uuid suffix | -| Pre-assign session id | `claude --session-id ` | **not possible** | -| Resume | `claude --resume ` | `codex resume ` | -| Fork | `claude --resume --fork-session` | `codex fork ` | -| Sub-agent transcripts | in a `subagents/` subdirectory | mixed in with the rest | -| Transcript written at launch | yes | **no — only on the first turn** | -| Resume writes | same file | same file (appends, id preserved) | - -`CODEX_HOME` defaults to `~/.codex` and must be honoured if set. - -### The rollout format - -Line 1 is always `session_meta`: - -```json -{"timestamp":"...","type":"session_meta","payload":{ - "session_id":"01a03f7c-...","cwd":"/Users/home/dev/foo", - "originator":"codex_cli_rs","cli_version":"0.149.1","source":"cli"}} -``` - -**The session id is the file name's uuid, not `session_meta.session_id`.** -That field is the *lineage root* and is repeated by every resume and fork of a -conversation — on this machine six rollout files share one value, which would -collide on `session_cache.sessionId`. The file name's uuid is unique per -rollout, is what `codex resume ` accepts (verified), and is what codex's own -`session_index.jsonl` keys on. - -Subsequent lines carry a `type` and a `payload.type`: - -- `response_item` / `message` with a `role` → **the authoritative text** -- `event_msg` / `user_message`, `agent_message` → a duplicate copy of the same - turns, emitted by some versions only -- `event_msg` / `token_count`, `task_started`, `task_complete` → bookkeeping -- `response_item` / `message`, `reasoning`, `custom_tool_call`, `custom_tool_call_output` -- `turn_context`, `world_state` → per-turn config - -Count **only** `response_item` messages: it is present in all 53 rollouts on -this machine while `event_msg` is missing from 6, and counting both would double -every turn. Skip `role: 'developer'` (CLI scaffolding) and skip user messages -opening with a simple XML tag — codex injects ``, -``, ``, `` as user turns, and -across every rollout on disk no genuine prompt starts with one. - -Timestamps are ISO-8601 UTC, so the same first/last scan Claude's parser already -does works unchanged. - ---- - -## 2. The one hard constraint - -**Codex has no `--session-id`.** Switchboard's whole new-session flow depends on -knowing the id *before* spawning: - -```js -const sessionId = crypto.randomUUID(); // app.js launchNewSession -claudeArgs.push('--session-id', sessionId) // main.js open-terminal -``` - -Worse, the rollout file does not exist until the user sends their first -message — a launched-but-unused Codex session leaves no trace on disk at all. - -So a new Codex session must be **launched under a temporary id and promoted to -its real id later**. - -### Sub-agent rollouts are not sessions - -Codex records sub-agent threads as ordinary rollout files, but refuses to resume -one: - -``` -cannot resume an unloaded multi-agent v2 sub-agent through its parent; -resume the parent first, or use thread/read to inspect it -``` - -They are marked in `session_meta` by any of `thread_source: 'subagent'`, -`parent_thread_id`, `agent_path`, `agent_nickname` — not all together, and older -rollouts carry none. `forked_from_id` is **not** one of them: a fork is a real -session. - -The indexer skips them. Claude's subagent transcripts are already excluded (they -live in a `subagents/` subdirectory the indexer never scans), so this keeps the -two consistent, and it avoids sidebar rows whose only action always fails. Of 54 -rollouts here, 23 are dropped this way or for having no user turn, leaving 31. - -### The handshake - -`CODEX_INTERNAL_ORIGINATOR_OVERRIDE` is copied verbatim into -`session_meta.payload.originator`. Verified: - -``` -env CODEX_INTERNAL_ORIGINATOR_OVERRIDE=switchboard_probe_1 codex -→ {"session_id":"01a03f7c-...","originator":"switchboard_probe_1"} -``` - -That gives an exact, race-free marker: - -1. Renderer makes a temp uuid, same as today. -2. Main spawns codex with `CODEX_INTERNAL_ORIGINATOR_OVERRIDE=switchboard-`. -3. The codex watcher sees a new rollout, reads line 1, matches `originator`. -4. Main sends `session-detected(tempId, realId)`. - -Step 4 needs no new renderer code — `app.js:208 onSessionDetected` already -re-keys `openSessions`, `activeSessionId`, and the header. It is currently dead -code (nothing in main emits it). This revives it. - -The tag is `switchboard_`. The charset matters: the -value is forwarded as an HTTP header, and the binary carries an "ignoring -invalid thread originator header value" path, so it is kept to `[a-z0-9_]`. - -**Fallback, required.** The env var is internal and may be removed. If no -rollout matches the originator, fall back to: a rollout whose `cwd` equals the -session's `projectPath`, started after the spawn (with a few seconds of clock -skew allowed). It explicitly refuses a rollout tagged for a *different* -switchboard launch, which is the one case two concurrent new sessions could -steal each other's transcript. - -**Backstops.** The watcher is the fast path, but a dropped `fs.watch` event -would strand a session under its temporary id forever, so `get-projects` also -sweeps — only while something is actually waiting, and only over transcripts -touched since the launch. - ---- - -## 3. Structure: a harness adapter - -Today the transcript format, the on-disk layout, and the launch command are -hardcoded across `main.js`, `session-cache.js`, `read-session-file.js`, and -`workers/scan-projects.js`. Add `harnesses/` with one module per CLI and a -registry: - -``` -harnesses/index.js → { claude, codex }, getHarness(id), availableHarnesses() -harnesses/claude.js → today's behaviour, moved not rewritten -harnesses/codex.js → new -``` - -Each adapter exports: - -```js -{ - id: 'codex', - label: 'Codex', - available(), // does its home dir / binary exist - sessionsRoot(), // dir to scan and fs.watch - listFolders(), // folder keys under that root - folderPath(folder), // key → absolute dir - readSessionFile(filePath, folder), // → the same session shape as today - transcriptPath(session), // → absolute .jsonl path - buildLaunch({ sessionId, isNew, projectPath, options }), - // → { cmd, args, env } -} -``` - -`readSessionFile` must keep returning the exact object -`upsertCachedSessions` already writes, so `session-cache.js` needs no shape -changes — only `readSessionFile(...)` → `getHarness(row.runtime).readSessionFile(...)`. - -### Folder keys - -`session_cache.folder` becomes a namespaced key: - -- Claude: `` — **unchanged**, no migration -- Codex: `codex/2026/08/26` - -`encodeProjectPath` output is `[a-zA-Z0-9-]+` only, so a key containing `/` -can never collide with an existing Claude row. One helper resolves both: - -```js -function folderPath(folder) { - return folder.startsWith('codex/') - ? path.join(CODEX_SESSIONS_DIR, folder.slice(6)) - : path.join(PROJECTS_DIR, folder); -} -``` - -Date-keyed folders work with the existing incremental machinery for free: -`getFolderIndexMtimeMs` gates on newest `.jsonl` mtime, so only today's and -yesterday's dirs are ever rescanned. Old dirs cost one `stat`. - -`cache_meta.projectPath` is meaningless for a date folder (many projects per -day) — store `NULL`. It is only read by the empty-project backfill in -`buildProjectsFromCache`, which already skips nulls. - ---- - -## 4. Data model and migration - -### Naming: `harnesses/` in code, `runtime` in the schema - -The module namespace is `harnesses/`, not `agents/`, because **`agent` is already -taken**: `feat/subagent-support` adds `parentSessionId`, `agentId`, `subagentType` -to `session_cache`, where `agentId` means a Claude *subagent*. "Harness" is also -the accurate word — what varies is the CLI program wrapping the model. - -The column is `runtime`, not `harness`, for a different reason: **it already -exists.** A build from a parallel branch left `runtime TEXT DEFAULT 'claude'` and -`sessionFile TEXT` in real databases (confirmed against a live db_version-5 DB: -859 rows, `runtime = 'claude'` throughout, `sessionFile` null throughout). Those -are exactly the two columns this design needs, with exactly the right semantics, -so they are adopted rather than shadowed by a second pair that would drift. - -The names differ between layer and schema, which is a small price for not -carrying two columns that mean the same thing. - -Two columns on `session_cache`: - -```sql -ALTER TABLE session_cache ADD COLUMN runtime TEXT DEFAULT 'claude'; -ALTER TABLE session_cache ADD COLUMN sessionFile TEXT; -``` - -- `runtime` — which CLI owns this session. The default means every existing row - is correct with no backfill. `NOT NULL` is deliberately omitted: the parallel - branch's column is nullable, and requiring it would force a table rebuild on - DBs that already have data. Reads go through `getHarness()`, which treats null - as Claude. -- `sessionFile` — absolute transcript path. Needed because a Codex filename is - `rollout--.jsonl`, not `.jsonl`. `NULL` on old rows, and - `transcriptPath()` falls back to `PROJECTS_DIR/folder/sessionId.jsonl`. - -Both go in the **schema reconciliation block** (`db.js`, the `PRAGMA -table_info` section), not the numbered `migrations` array. That block already -exists precisely because db_version can't be trusted across branches, and its -comment says so. - -`session_meta` (name, starred, archived) is keyed by sessionId alone and needs -no change — a renamed or pinned Codex session works on day one. - -### What an existing user sees on upgrade - -- **No cache wipe. No re-index of Claude sessions.** Both columns are - defaulted or nullable, so nothing is invalidated. -- Every existing session keeps its row and gets the Claude icon. -- On first reconcile, `~/.codex/sessions` is scanned and past Codex sessions - appear in the sidebar, grouped into the right projects by their `cwd`. -- If Codex isn't installed, `available()` is false: no scan, no watcher, and - the "+" menu doesn't offer Codex. Nothing errors. - -The one visible change for a Codex user is that their existing Codex history -shows up. That is the feature, not a regression. - ---- - -## 5. Launch, resume, fork - -`harnesses/codex.js buildLaunch`: - -| Case | Args | -|---|---| -| new | `codex` (+ `CODEX_INTERNAL_ORIGINATOR_OVERRIDE`) | -| resume | `codex resume ` | -| fork | `codex fork ` | - -Option mapping — Codex has different vocabulary, so it needs its own -dialog fields rather than reusing Claude's permission modes: - -| Switchboard | Claude | Codex | -|---|---|---| -| permission | `--permission-mode ` | `--ask-for-approval on-request\|never` | -| skip safety | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` | -| model | — | `--model ` (`codexModel`) | -| sandbox | — | `--sandbox read-only\|workspace-write\|danger-full-access` | -| extra dirs | `--add-dir` | `--add-dir` (same) | -| pre-launch cmd | prepended | prepended (same) | -| worktree | `--worktree` | not supported — hide the field | - -`cwd` is set by the PTY spawn, as today; `-C/--cd` is not needed. - -New settings keys (`SETTING_DEFAULTS`, global + per project): -`defaultAgent`, `codexSandbox`, `codexApproval`, `codexModel`. Adding keys is -backward-compatible — `get-effective-settings` starts from `SETTING_DEFAULTS` -and only overrides defined values. - ---- - -## 6. UI - -**Sidebar icon.** `buildSessionItem` already prepends a `.terminal-badge` for -`type === 'terminal'`. Same slot, driven by `session.runtime`: Claude glyph, -Codex glyph, or the terminal glyph. Add `is-codex` next to `is-terminal`. - -**New-session popover.** Rows are generated from a `get-harnesses` IPC rather -than hardcoded, so a machine without codex installed simply never sees it. It -renders Claude immediately and re-renders when main answers, so it never appears -empty. - -**Config dialog.** Takes a runtime. Codex gets sandbox / approval / model; -Claude keeps permission mode / worktree / Chrome. They are not translations of -each other — showing Claude's controls for a codex session would offer settings -that are silently dropped at launch. - -**Resume.** No UI change. `openSession` reads `session.runtime` from the row and -main dispatches on it. - ---- - -## 7. What stays Claude-only - -Gate these on `runtime === 'claude'` rather than trying to generalise now: - -- **MCP / IDE emulation** — `--ide`, `CLAUDE_CODE_SSE_PORT`, `mcp-bridge.js`. - Codex has its own MCP model; skip it entirely for Codex sessions. -- **Fork / plan-accept detection** — `session-transitions.js` matches on - `forkedFrom`, `planContent`, and `slug`, none of which exist in a rollout. - Codex `fork` is an explicit command, so no detection is needed. -(Busy-state was on this list until the TUI was probed — see below.) -- **Scheduled tasks** — `schedule-runner.js`, `createScheduleSession`. -- **Stats view** — reads `~/.claude/stats-cache.json`. -- **Slug grouping** — no slug in a rollout, so Codex sessions render ungrouped. - -### Activity: working, waiting, blocked - -Probed from a live session rather than guessed. Codex uses **the same terminal -title protocol as Claude**: a braille spinner frame (U+2800–U+28FF) prefixes the -title while a turn runs. The difference is idle — Claude marks it with `✳`, -codex simply drops the prefix, so for codex "not a spinner" *is* idle. Reading -it as "no information" would leave sessions spinning in the sidebar forever, and -treating a plain Claude title the same way would end a busy state early. Hence -`parseTitleState` per harness. - -The other two states arrive as OSC 9, but only if notifications are on, so the -launch forces them: - -``` --c tui.notifications=true --c tui.notification_method="osc9" --c tui.notification_condition="always" -``` - -Same reasoning as `TERM_PROGRAM=iTerm.app` for Claude — the signal is what drives -the sidebar. `unfocused` would suppress exactly the case that matters. Passed as -`-c` overrides, so the user's own `config.toml` is untouched. - -Codex sends two kinds, with wording nothing like Claude's: - -| Payload | Means | -|---|---| -| `Approval requested: ` | blocked on the user | -| the agent's final message | turn finished, response waiting | - -So classification moves into the harness too, and main hands the renderer a -*kind* rather than the renderer re-deriving one from text. Verified end to end: -busy at 7.5s, idle + `kind=idle` at 13.2s carrying the answer, `kind=attention` -at 30.3s carrying the approval request. - -**JSONL viewer** has a real Codex path rather than a gate — see step 7. The -mapping is exact enough that all 96 tool calls in a real rollout pair with their -results, and the existing renderer needed no change. - ---- - -## 8. Implementation order - -Each step leaves the app working. - -1. **`harnesses/` + move Claude into it.** Pure refactor, no behaviour change. - Existing tests must still pass. -2. **DB columns + `folderPath()` helper.** Still Claude-only; proves the - migration is a no-op on a real DB. -3. **Codex indexer.** `harnesses/codex.js readSessionFile`, folder listing, - watcher on `$CODEX_HOME/sessions`. Past Codex sessions appear, read-only. -4. **Resume.** `codex resume ` from a sidebar click. This is the first - point the feature is useful. `codex resume` accepts the same flags as a - fresh launch, so one option mapping covers both. Enumerated values are - validated against what the CLI accepts rather than interpolated — they come - from stored settings, which outlive the codex version that understood them. - The sidebar badge moves here from step 6: without it there is no way to tell - which row is which, so resume cannot be tested by hand. -5. **New session + originator handshake.** Temp id, `session-detected`, - the cwd/mtime fallback. `onSessionDetected` in the renderer already existed - but was dead code (nothing emitted it) and did not re-key `pendingSessions`, - so the temp id kept being re-injected as a phantom row — fixed alongside. -6. **UI.** Agent picker in the popover, icon in the sidebar, Codex fields in - the config dialog. -7. **Fork + JSONL viewer.** A fork runs on the CLI that wrote the session it - forks, not the default — `codex fork` cannot continue a Claude transcript. - The viewer is fed by a per-harness `toViewerEntries`, so it never learns a - second transcript format: a codex tool call becomes a `tool_use` block, its - output a `tool_result` keyed by the same call id, and a reasoning summary a - `thinking` block. Claude's is identity. Normalising in main rather than the - renderer keeps the viewer ignorant of which CLI wrote the file. - -### Tests worth writing - -- `harnesses/codex.js readSessionFile` against a fixture rollout (session_meta, - user_message, agent_message, tool calls) → asserts summary, counts, cwd, times. -- `folderPath()` round-trip for both namespaces, including a project path that - hits the 200-char hash branch of `encodeProjectPath`. -- Originator matching: right file among several created in the same second, - and the cwd/mtime fallback when originator is absent. -- A migration test in the style of `test/db-schema-reconcile.test.js`: open a - pre-upgrade DB, run reconciliation, assert existing rows read back with - `runtime = 'claude'` and that nothing was deleted. - ---- - -## 9. Risks - -| Risk | Mitigation | -|---|---| -| `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` is internal and may vanish | cwd + birthtime fallback, always present | -| `codex migrate-rollouts` suggests sessions may move to sqlite thread history | rollout dir is still written by 0.149.1; adapter isolates the change to one file | -| Codex prompts "do you trust this directory?" on first launch in a new dir | user answers in the terminal, same as Claude's trust prompt | -| A launched-but-unused Codex session leaves no file | it stays a pending row and is cleaned up by the existing pending reconciliation | diff --git a/file-management.js b/file-management.js new file mode 100644 index 00000000..c7b6b7d3 --- /dev/null +++ b/file-management.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); +const { resolveProjectEntry } = require('./project-files'); + +// Resolve the parent, not the leaf: renaming/trashing a symlink must act on +// the link itself. Parents still have to resolve inside the project. +function resolveManagedEntry(projectPath, relativePath) { + if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) { + throw new Error('Invalid project-relative path'); + } + const { root } = resolveProjectEntry(projectPath); + const candidate = path.resolve(root, relativePath); + if (candidate === root) throw new Error('The project folder itself cannot be changed'); + const { resolved: parent } = resolveProjectEntry(root, path.relative(root, path.dirname(candidate))); + const filePath = path.join(parent, path.basename(candidate)); + const stat = fs.lstatSync(filePath); + return { root, filePath, stat }; +} + +async function manageProjectEntry(projectPath, relativePath, action, newName, { shell, confirmTrash }) { + const { filePath, stat } = resolveManagedEntry(projectPath, relativePath); + const result = { action, projectPath, relativePath, filePath }; + if (action === 'reveal') { + shell.showItemInFolder(filePath); + } else if (action === 'open-folder') { + if (!stat.isDirectory()) throw new Error('Not a folder'); + const error = await shell.openPath(filePath); + if (error) throw new Error(error); + } else if (action === 'rename') { + if (typeof newName !== 'string' || !newName.trim() || newName === '.' || newName === '..' || /[/\\\0]/.test(newName)) { + throw new Error('Enter a file or folder name without path separators'); + } + const newFilePath = path.join(path.dirname(filePath), newName); + if (newFilePath === filePath) return { ...result, cancelled: true }; + // lstat also catches dangling symlinks, which existsSync would miss. + try { + fs.lstatSync(newFilePath); + throw new Error('A file or folder with that name already exists'); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + fs.renameSync(filePath, newFilePath); + result.newFilePath = newFilePath; + result.newRelativePath = path.join(path.dirname(relativePath), newName); + } else if (action === 'trash') { + if (!await confirmTrash(filePath, stat.isDirectory())) return { ...result, cancelled: true }; + // A native confirmation is asynchronous. Recheck before acting in case + // the item or one of its parents was replaced while the dialog was open. + const current = resolveManagedEntry(projectPath, relativePath); + if (current.filePath !== filePath || current.stat.ino !== stat.ino || current.stat.dev !== stat.dev) { + throw new Error('The item changed while confirming. Refresh the files and try again.'); + } + await shell.trashItem(filePath); + } else { + throw new Error('Unknown file action'); + } + return result; +} + +module.exports = { manageProjectEntry, resolveManagedEntry }; diff --git a/git.js b/git.js new file mode 100644 index 00000000..be7b57a3 --- /dev/null +++ b/git.js @@ -0,0 +1,290 @@ +// git.js — the few git commands Switchboard runs itself, for project worktrees. +// +// Every call goes through execFile with an argv array and no shell, so a path +// or branch name can never be read as shell syntax. Errors carry git's stderr +// as their message. Nothing here is used unless a project attaches a folder +// "on a branch". + +const { execFile } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const MAX_BUFFER = 4 * 1024 * 1024; +const MAX_DIFF_LENGTH = 256 * 1024; +const MAX_UNTRACKED_STAT_BYTES = 2 * 1024 * 1024; + +function run(args, cwd) { + return new Promise((resolve, reject) => { + execFile('git', args, { + cwd, + maxBuffer: MAX_BUFFER, + // Never hang on a credential prompt; a worktree add has no reason to ask. + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }, (err, stdout, stderr) => { + if (err) { + const error = new Error(String(stderr || err.message).trim() || `git ${args[0]} failed`); + error.code = err.code; + error.stderr = String(stderr || ''); + reject(error); + return; + } + resolve(String(stdout).trim()); + }); + }); +} + +/** Run git without trimming its output. Some machine-readable formats use NULs. */ +function runRaw(args, cwd, { allowExitCodes = [] } = {}) { + return new Promise((resolve, reject) => { + execFile('git', args, { + cwd, + maxBuffer: MAX_BUFFER, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }, (err, stdout, stderr) => { + if (!err || allowExitCodes.includes(err.code)) { + resolve(String(stdout)); + return; + } + const error = new Error(String(stderr || err.message).trim() || `git ${args[0]} failed`); + error.code = err.code; + error.stderr = String(stderr || ''); + error.stdout = String(stdout || ''); + reject(error); + }); + }); +} + +async function runOr(args, cwd, fallback = '') { + try { return await runRaw(args, cwd); } catch { return fallback; } +} + +async function version() { + try { return await run(['--version']); } catch { return null; } +} + +async function isGitRepo(dir) { + try { return (await run(['rev-parse', '--is-inside-work-tree'], dir)) === 'true'; } catch { return false; } +} + +/** Top-level directory of the checkout that contains dir. Rejects outside a repo. */ +async function repoRoot(dir) { + return run(['rev-parse', '--show-toplevel'], dir); +} + +async function branchExists(repo, name) { + try { + await run(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], repo); + return true; + } catch { + return false; + } +} + +/** Check the branch out at targetPath, creating the branch from HEAD if needed. */ +async function worktreeAdd(repo, targetPath, branch) { + if (await branchExists(repo, branch)) { + await run(['worktree', 'add', targetPath, branch], repo); + } else { + await run(['worktree', 'add', '-b', branch, targetPath], repo); + } +} + +/** Remove a worktree. Refuses a dirty one unless force; the branch always stays. */ +async function worktreeRemove(repo, targetPath, { force = false } = {}) { + const args = ['worktree', 'remove']; + if (force) args.push('--force'); + args.push(targetPath); + await run(args, repo); + try { await run(['worktree', 'prune'], repo); } catch {} +} + +/** True when git refused to remove a worktree because it has local changes. */ +function isDirtyWorktreeError(err) { + return /modified or untracked|use --force|uncommitted|contains modified/i.test(String(err?.message || '')); +} + +/** The repository's shared .git directory, absolute, from any of its worktrees. */ +async function gitCommonDir(dir) { + const out = await run(['rev-parse', '--git-common-dir'], dir); + return path.resolve(dir, out); +} + +/** Branch, whether anything is modified or untracked, and ahead/behind upstream. */ +async function status(dir) { + const branch = await run(['rev-parse', '--abbrev-ref', 'HEAD'], dir); + const porcelain = await run(['status', '--porcelain'], dir); + let ahead = null; + let behind = null; + try { + const counts = await run(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], dir); + const [b, a] = counts.split(/\s+/).map(Number); + if (Number.isFinite(a) && Number.isFinite(b)) { ahead = a; behind = b; } + } catch {} + return { branch, dirty: porcelain.length > 0, ahead, behind }; +} + +const CONFLICT_CODES = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']); + +/** Parse `git status --porcelain=v1 -z` without losing unusual filenames. */ +function parsePorcelain(output) { + const records = String(output || '').split('\0'); + const changes = []; + for (let i = 0; i < records.length; i += 1) { + const record = records[i]; + if (!record || record.length < 3) continue; + const code = record.slice(0, 2); + const indexStatus = code[0]; + const worktreeStatus = code[1]; + const filePath = record.slice(3); + let oldPath = null; + // With -z, a rename/copy is the destination followed by a second, + // NUL-terminated source pathname. + if (/[RC]/.test(code) && i + 1 < records.length) oldPath = records[++i] || null; + + let statusName = 'modified'; + if (CONFLICT_CODES.has(code)) statusName = 'conflicted'; + else if (code === '??') statusName = 'untracked'; + else if (/[RC]/.test(code)) statusName = 'renamed'; + else if (code.includes('D')) statusName = 'deleted'; + else if (code.includes('A')) statusName = 'added'; + + changes.push({ + path: filePath, + oldPath, + code, + status: statusName, + indexStatus, + worktreeStatus, + staged: indexStatus !== ' ' && indexStatus !== '?', + }); + } + return changes; +} + +function parseShortStat(output) { + const text = String(output || ''); + const insertions = Number(text.match(/(\d+) insertion/)?.[1] || 0); + const deletions = Number(text.match(/(\d+) deletion/)?.[1] || 0); + return { insertions, deletions }; +} + +function countUntrackedInsertions(dir, changes) { + let insertions = 0; + for (const change of changes) { + if (change.status !== 'untracked') continue; + const absolute = path.resolve(dir, change.path); + const relative = path.relative(dir, absolute); + if (relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) continue; + try { + const stat = fs.statSync(absolute); + if (!stat.isFile() || stat.size > MAX_UNTRACKED_STAT_BYTES) continue; + const content = fs.readFileSync(absolute); + if (content.includes(0)) continue; + if (content.length) insertions += content.toString('utf8').split('\n').length - (content.at(-1) === 10 ? 1 : 0); + } catch {} + } + return insertions; +} + +function parseCommits(output) { + return String(output || '') + .split('\x1e') + .map(record => record.replace(/^\n+|\n+$/g, '')) + .filter(Boolean) + .map((record) => { + const [hash, shortHash, author, date, subject] = record.split('\0'); + return { hash, shortHash, author, date, subject }; + }); +} + +function gitOperation(gitDir) { + const exists = (name) => fs.existsSync(path.join(gitDir, name)); + if (exists('rebase-merge') || exists('rebase-apply')) return 'rebase'; + if (exists('MERGE_HEAD')) return 'merge'; + if (exists('CHERRY_PICK_HEAD')) return 'cherry-pick'; + if (exists('REVERT_HEAD')) return 'revert'; + if (exists('BISECT_LOG')) return 'bisect'; + return null; +} + +/** Read-only state used by the project Git tab. */ +async function snapshot(dir, { commitLimit = 20 } = {}) { + if (!await isGitRepo(dir)) return { git: false }; + + const [branchName, head, porcelain, counts, logOutput, statOutput, gitDirOutput] = await Promise.all([ + runOr(['rev-parse', '--abbrev-ref', 'HEAD'], dir), + runOr(['rev-parse', '--short', 'HEAD'], dir), + runRaw(['status', '--porcelain=v1', '-z', '--untracked-files=all'], dir), + runOr(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], dir, ''), + runOr(['log', `-${Math.max(1, Math.min(100, Number(commitLimit) || 20))}`, '--date=iso-strict', '--pretty=format:%H%x00%h%x00%an%x00%aI%x00%s%x1e'], dir, ''), + runOr(['diff', '--shortstat', 'HEAD', '--'], dir, ''), + runOr(['rev-parse', '--git-dir'], dir, ''), + ]); + + const changes = parsePorcelain(porcelain); + const [behindRaw, aheadRaw] = counts.trim().split(/\s+/); + const ahead = counts ? Number(aheadRaw) : null; + const behind = counts ? Number(behindRaw) : null; + const stats = parseShortStat(statOutput); + stats.insertions += countUntrackedInsertions(dir, changes); + const gitDir = gitDirOutput ? path.resolve(dir, gitDirOutput.trim()) : null; + const detached = branchName.trim() === 'HEAD'; + + return { + git: true, + branch: detached ? '' : branchName.trim(), + detached, + head: head.trim(), + dirty: changes.length > 0, + ahead: Number.isFinite(ahead) ? ahead : null, + behind: Number.isFinite(behind) ? behind : null, + operation: gitDir ? gitOperation(gitDir) : null, + changes, + stats, + commits: parseCommits(logOutput), + }; +} + +function safeRelativePath(dir, filePath) { + if (typeof filePath !== 'string' || !filePath) throw new Error('File path is required'); + const absolute = path.resolve(dir, filePath); + const relative = path.relative(dir, absolute); + if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { + throw new Error('File is outside the repository'); + } + return { absolute, relative }; +} + +/** Return a read-only unified diff for one currently changed file. */ +async function fileDiff(dir, filePath) { + const { absolute, relative } = safeRelativePath(dir, filePath); + const changes = parsePorcelain(await runRaw(['status', '--porcelain=v1', '-z', '--untracked-files=all'], dir)); + const change = changes.find(item => item.path === relative || item.path === filePath); + if (!change) throw new Error('File is not currently changed'); + + let diff = ''; + if (change.status === 'untracked') { + const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; + diff = await runRaw(['diff', '--no-index', '--no-ext-diff', '--unified=3', '--', nullPath, absolute], dir, { allowExitCodes: [1] }); + } else { + try { + diff = await runRaw(['diff', '--no-ext-diff', '--unified=3', 'HEAD', '--', relative], dir); + } catch { + const [staged, unstaged] = await Promise.all([ + runOr(['diff', '--cached', '--no-ext-diff', '--unified=3', '--', relative], dir, ''), + runOr(['diff', '--no-ext-diff', '--unified=3', '--', relative], dir, ''), + ]); + diff = [staged, unstaged].filter(Boolean).join('\n'); + } + } + + const truncated = diff.length > MAX_DIFF_LENGTH; + if (truncated) diff = diff.slice(0, MAX_DIFF_LENGTH) + '\n\n… diff truncated by Switchboard …\n'; + return { path: relative, diff, truncated }; +} + +module.exports = { + run, version, isGitRepo, repoRoot, branchExists, + worktreeAdd, worktreeRemove, isDirtyWorktreeError, gitCommonDir, status, + parsePorcelain, snapshot, fileDiff, +}; diff --git a/harnesses/claude.js b/harnesses/claude.js index 6f27e44a..74bed717 100644 --- a/harnesses/claude.js +++ b/harnesses/claude.js @@ -228,31 +228,30 @@ function matchesLaunch(signals, { forkFrom, spawnedAt }) { // --- Transcript parsing --- const HEAD_BYTES = 4096; // guard window for append-only transcripts -const TEXT_CONTENT_CAP = 8000; // how much body text the search index keeps -const TEXT_LINE_CAP = 500; function hashHead(fd, stat) { const n = Math.min(HEAD_BYTES, stat.size); if (n === 0) return ''; const buf = Buffer.allocUnsafe(n); if (fs.readSync(fd, buf, 0, n, 0) !== n) throw new Error('JSONL head changed during read'); - // Version the state: pre-harness rows lack raw timestamp accumulators. + // Version the state: v2 cached capped text and included non-conversation records. + // Rebuild those rows only when their transcript next changes. // Include file identity so an atomic replacement with the same prefix resets. - return 'v2:' + crypto.createHash('sha1') + return 'v3:' + crypto.createHash('sha1') .update(`${stat.dev}:${stat.ino}:`).update(buf).digest('hex'); } /** Accumulator. Every field is either a first-occurrence or a running total, * which is what makes resuming mid-file valid. */ function emptyState() { - return { summary: '', messageCount: 0, textContent: '', slug: null, customTitle: null, aiTitle: null, firstTimestamp: null, lastTimestamp: null }; + return { summary: '', messageCount: 0, textParts: [], slug: null, customTitle: null, aiTitle: null, firstTimestamp: null, lastTimestamp: null }; } function stateFrom(prev) { return { summary: prev.summary || '', messageCount: prev.messageCount || 0, - textContent: prev.textContent || '', + textParts: prev.textContent ? [prev.textContent] : [], slug: prev.slug || null, customTitle: prev.customTitle || null, aiTitle: prev.aiTitle || null, @@ -274,15 +273,18 @@ function applyLine(line, st) { if (entry.type === 'custom-title' && entry.customTitle) st.customTitle = entry.customTitle; if (entry.type === 'ai-title' && entry.aiTitle) st.aiTitle = entry.aiTitle; - if (entry.type === 'user' || entry.type === 'assistant' || - (entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant'))) { + const isConversationMessage = entry.type === 'user' || entry.type === 'assistant' || + (entry.type === 'message' && (entry.role === 'user' || entry.role === 'assistant')); + if (isConversationMessage) { st.messageCount++; } const msg = entry.message; const text = typeof msg === 'string' ? msg : (typeof msg?.content === 'string' ? msg.content : - (msg?.content?.[0]?.text || '')); + (Array.isArray(msg?.content) ? msg.content + .filter(block => block?.type === 'text' && typeof block.text === 'string') + .map(block => block.text).join('\n') : '')); if (!st.summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) { // Skip local command messages (! prefix) — use the next real user message @@ -293,9 +295,8 @@ function applyLine(line, st) { } } - if (text && st.textContent.length < TEXT_CONTENT_CAP) { - st.textContent += text.slice(0, TEXT_LINE_CAP) + '\n'; - } + // Search every conversation text block without tools, thinking, or bookkeeping. + if (isConversationMessage && text) st.textParts.push(text); } /** @@ -335,7 +336,7 @@ function readSessionFile(filePath, folder, projectPath, prev = null) { created: st.firstTimestamp || stat.birthtime.toISOString(), modified: st.lastTimestamp || fileMtime, fileMtime, - messageCount: st.messageCount, textContent: st.textContent, + messageCount: st.messageCount, textContent: st.textParts.join('\n'), slug: st.slug, customTitle: st.customTitle, aiTitle: st.aiTitle, firstTimestamp: st.firstTimestamp, lastTimestamp: st.lastTimestamp, headHash, @@ -400,12 +401,20 @@ function classifyNotification(message) { // --- Launch --- +// The levels `claude --effort` accepts. Checked rather than passed blind: a +// stored setting can outlive the claude version that understood it, and an +// unknown level fails the launch. +const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + /** * Argv for the claude binary. Returned as an array so the caller can quote it * for the target shell rather than building a command string here. */ function buildLaunchArgs({ sessionId, isNew, options }) { const args = []; + // This flag accepts multiple values; the session flag must delimit its value + // so the first prompt cannot be consumed as another allowed tool. + if (options?.allowedTools) args.push('--allowedTools', String(options.allowedTools)); if (options?.forkFrom) { args.push('--resume', String(options.forkFrom), '--fork-session'); } else if (isNew) { @@ -420,6 +429,13 @@ function buildLaunchArgs({ sessionId, isNew, options }) { } else if (options.permissionMode) { args.push('--permission-mode', String(options.permissionMode)); } + // Empty leaves the choice to claude's own default. + if (options.model) { + args.push('--model', String(options.model)); + } + if (EFFORT_LEVELS.has(options.effort)) { + args.push('--effort', options.effort); + } // --worktree only applies when STARTING a session — it creates a fresh // isolated git worktree. Resuming (isNew === false) must reuse the // session's existing directory, so ignore the worktree option on resume @@ -447,6 +463,12 @@ function buildLaunchArgs({ sessionId, isNew, options }) { args.push('--append-system-prompt', String(options.appendSystemPrompt)); } + // A first prompt, as the positional argument. Only for a brand-new session: + // a resume continues where it was, and a fork carries its parent's prompt. + if (isNew && !options?.forkFrom && options?.initialPrompt) { + args.push(String(options.initialPrompt)); + } + return args; } diff --git a/harnesses/codex.js b/harnesses/codex.js index a536bf1a..19700e52 100644 --- a/harnesses/codex.js +++ b/harnesses/codex.js @@ -213,7 +213,9 @@ function messageText(payload) { const content = payload?.content; if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; - return content.map(c => (c && typeof c.text === 'string' ? c.text : '')).join(''); + return content + .filter(c => c && ['text', 'input_text', 'output_text'].includes(c.type) && typeof c.text === 'string') + .map(c => c.text).join('\n'); } /** @@ -235,7 +237,7 @@ function readSessionFile(filePath, folder) { let projectPath = null; let summary = ''; let messageCount = 0; - let textContent = ''; + const textParts = []; let firstTimestamp = null; let lastTimestamp = null; @@ -276,7 +278,9 @@ function readSessionFile(filePath, folder) { messageCount++; if (!summary && role === 'user' && text) summary = text.slice(0, 120); - if (text && textContent.length < 8000) textContent += text.slice(0, 500) + '\n'; + // Keep full conversation text; tool calls/results and reasoning never + // reach this branch because they are not user/assistant messages. + if (text) textParts.push(text); } // No cwd means no project to file it under; no user turn means the session @@ -292,7 +296,7 @@ function readSessionFile(filePath, folder) { created: firstTimestamp || stat.birthtime.toISOString(), modified: lastTimestamp || stat.mtime.toISOString(), fileMtime: stat.mtime.toISOString(), - messageCount, textContent, + messageCount, textContent: textParts.join('\n'), slug: null, customTitle: null, aiTitle: null, }; } catch { @@ -534,6 +538,40 @@ function classifyNotification(message) { const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']); const APPROVAL_POLICIES = new Set(['on-request', 'never']); +// Reasoning efforts across codex's model catalog. Codex has no --effort flag: +// it is the model_reasoning_effort config key, passed as a -c override. The +// value is parsed as TOML, so only these exact words are ever written into it. +const REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']); + +/** + * Codex's model list, from the cache its own model picker reads + * ($CODEX_HOME/models_cache.json). Each model names the reasoning efforts it + * supports, which the settings form uses to keep an effort the model rejects + * out of a launch. Read rather than running `codex debug models`: the main + * process does not have the login shell's PATH (see available()). Empty when + * codex has never fetched it or the file is not the shape expected. + */ +function readModelCatalog() { + let data; + try { + data = JSON.parse(fs.readFileSync(path.join(codexHome(), 'models_cache.json'), 'utf8')); + } catch { + return []; + } + const out = []; + for (const m of Array.isArray(data?.models) ? data.models : []) { + if (!m || typeof m.slug !== 'string' || !m.slug) continue; + const levels = Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : []; + out.push({ + slug: m.slug, + label: typeof m.display_name === 'string' && m.display_name ? m.display_name : m.slug, + efforts: levels.map(l => (typeof l === 'string' ? l : l?.effort)).filter(e => typeof e === 'string' && e), + defaultEffort: typeof m.default_reasoning_level === 'string' ? m.default_reasoning_level : null, + visible: m.visibility !== 'hide', + }); + } + return out; +} /** * Argv for the codex binary. @@ -585,7 +623,17 @@ function buildLaunchArgs({ sessionId, isNew, options }) { if (options.codexModel) { args.push('--model', String(options.codexModel)); } - if (options.addDirs) { + if (REASONING_EFFORTS.has(options.codexEffort)) { + args.push('-c', `model_reasoning_effort="${options.codexEffort}"`); + } + // --add-dir names extra writable roots, and codex refuses to start when + // the sandbox cannot grant them ("effective permissions do not allow + // additional writable roots"). Read-only, the default, can already read + // every path, and the bypass flag can already write everywhere, so the + // dirs only go on the command line for the two modes that take them. + const canAddDirs = !options.dangerouslySkipPermissions && + (options.codexSandbox === 'workspace-write' || options.codexSandbox === 'danger-full-access'); + if (options.addDirs && canAddDirs) { const dirs = String(options.addDirs).split(',').map(d => d.trim()).filter(Boolean); for (const dir of dirs) { args.push('--add-dir', dir); @@ -593,6 +641,12 @@ function buildLaunchArgs({ sessionId, isNew, options }) { } } + // A first prompt, as codex's positional argument. Fresh sessions only; a + // resume or fork already has a conversation. + if (isNew && !options?.forkFrom && options?.initialPrompt) { + args.push(String(options.initialPrompt)); + } + return args; } @@ -601,6 +655,7 @@ module.exports = { titleIndexPath, readSessionTitles, parseTitleState, classifyNotification, buildLaunchArgs, launchEnv, originatorTag, readLaunchSignals, matchesLaunch, needsIdDetection, + readModelCatalog, available, codexHome, sessionsRoot, listFolders, folderPath, folderForProject, listTranscripts, sessionIdFromPath, transcriptPath, isSubagentMeta, deriveProjectPath, diff --git a/main.js b/main.js index 26d79232..20b7f954 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron'); +const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, protocol, screen, shell } = require('electron'); const { Worker } = require('worker_threads'); const path = require('path'); const fs = require('fs'); @@ -30,15 +30,21 @@ try { require('electron-reloader')(module, { watchRenderer: true }); } catch {}; // LC_CTYPE when the launch environment has no locale. See pty-env.js. const { buildPtyEnv } = require('./pty-env'); const cleanPtyEnv = buildPtyEnv(process.env); -const { shouldStartFresh } = require('./session-launch'); +const { shouldStartFresh, shouldBlockArchivedSession } = require('./session-launch'); +const { createTerminalActivity } = require('./terminal-activity'); // Shell profiles → shell-profiles.js const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles'); -const { startScheduler } = require('./schedule-runner'); +const { scanSchedules } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); const { resolveEffectiveSettings } = require('./resolve-effective-settings'); -const { listProjectDirectory, readProjectFile } = require('./project-files'); +const { listProjectDirectory, readProjectFile, readPreviewFile, openFileExternally } = require('./project-files'); +const { manageProjectEntry } = require('./file-management'); +const { resolveTerminalFiles } = require('./terminal-file-links'); +const { PREVIEW_SCHEME, PREVIEW_SCHEMES, handlePreviewAssetRequest } = require('./preview-assets'); +protocol.registerSchemesAsPrivileged(PREVIEW_SCHEMES); const { createTaskManager } = require('./task-manager'); +const { lastAssistantMessage } = require('./session-preview'); // --- Auto-updater (only in packaged builds) --- @@ -77,8 +83,10 @@ const { upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, getSetting, setSetting, deleteSetting, + copySessionAssignment, moveSessionAssignment, rekeyPlanLinks, closeDb, } = require('./db'); +const dbModule = require('./db'); const { getHarness, DEFAULT_HARNESS, transcriptPath, availableHarnesses, allHarnesses, progressBusyState, harnessForFolder: getHarnessForFolder } = require('./harnesses'); @@ -92,6 +100,7 @@ const MAX_BUFFER_SIZE = 256 * 1024; // Active PTY sessions const activeSessions = new Map(); let mainWindow = null; +let appIsQuitting = false; function createWindow() { // Restore saved window bounds @@ -126,6 +135,7 @@ function createWindow() { webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, + nodeIntegrationInSubFrames: false, contextIsolation: true, }, }); @@ -284,6 +294,28 @@ const { refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, refreshHarnessTitles, initializeHiddenProjectTimestamps } = sessionCache; +// --- Projects (a piece of work with a folder on disk) --- +const projects = require('./projects'); +projects.init({ + db: dbModule, + log, + buildProjectsFromCache, + notifyRendererProjectsChanged, + isHarnessId: (id) => allHarnesses().some(h => h.id === id), + plansDir: PLANS_DIR, +}); +// An upgrade can change the working rules in the brief. Bring every project's +// managed blocks up to date once at startup; unchanged files are not written. +projects.syncAllProjectBriefs().catch(err => log.error('[projects] brief sync failed:', err?.message || String(err))); +// Watch every project's plan-tracker.md and todos.md so a tick made by a +// session is credited to it and the page refreshes. +projects.initPlanWatch({ + activeSessions, + send: (channel, ...args) => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args); + }, +}); + // --- IPC: browse-folder --- ipcMain.handle('browse-folder', async () => { const result = await dialog.showOpenDialog(mainWindow, { @@ -364,12 +396,117 @@ ipcMain.handle('remove-project', (_event, projectPath) => { } }); +// --- IPC: projects --- +// Every mutating handler notifies the renderer itself (projects.js), so both +// the Sessions and the Projects tab refresh from one event. +function guarded(fn) { + return async (_event, ...args) => { + try { return await fn(...args); } catch (err) { + log.error('[projects]', err); + return { error: err.message }; + } + }; +} +ipcMain.handle('get-project-tree', guarded((showArchived) => { + // Mirrors get-projects: until the cache is populated there is nothing to + // file, and the renderer is told via projects-changed once there is. + if (!isCachePopulated() || !isSearchIndexPopulated()) return { projects: [] }; + return projects.buildProjectTree(!!showArchived); +})); +ipcMain.handle('create-project', guarded((spec) => projects.createProject(spec || {}))); +ipcMain.handle('update-project', guarded((id, patch) => projects.updateProject(id, patch || {}))); +ipcMain.handle('delete-project', guarded((id) => projects.deleteProject(id))); +ipcMain.handle('attach-project-folder', guarded((id, spec) => projects.attachFolder(id, spec || {}))); +ipcMain.handle('detach-project-folder', guarded((id, folderPath, opts) => projects.detachFolder(id, folderPath, opts || {}))); +ipcMain.handle('set-session-assignment', guarded((sessionId, projectId, trackId) => { + const cleanProjectId = projectId || null; + const cleanTrackId = trackId || null; + const result = projects.assignSession(sessionId, cleanProjectId, cleanTrackId); + // Raw terminals have no transcript to rehydrate this relationship from. + // Keep the live PTY metadata aligned with the durable renderer descriptor so + // a renderer reload cannot put a moved terminal back in its old location. + if (!result?.error) { + const session = activeSessions.get(sessionId); + if (session?.isPlainTerminal) { + session.projectId = cleanProjectId; + session.trackId = cleanProjectId ? cleanTrackId : null; + } + } + return result; +})); +// Scheduled tasks. The tick that fires them is startScheduleTicker below. +ipcMain.handle('list-schedules', guarded(() => projects.listSchedules())); +ipcMain.handle('create-schedule', guarded((spec) => projects.createSchedule(spec || {}))); +ipcMain.handle('update-schedule', guarded((id, patch) => projects.updateSchedule(id, patch || {}))); +ipcMain.handle('delete-schedule', guarded((id) => projects.deleteSchedule(id))); +ipcMain.handle('resolve-schedule-launch', guarded((id) => projects.resolveScheduleLaunch(id))); +ipcMain.handle('get-schedule-context', guarded((spec) => projects.resolveScheduleContext(spec || {}))); +ipcMain.handle('create-track', guarded((projectId, spec) => projects.createTrack(projectId, spec || {}))); +ipcMain.handle('update-track', guarded((id, patch) => projects.updateTrack(id, patch || {}))); +ipcMain.handle('delete-track', guarded((id, options = {}) => { + const track = dbModule.getTrack(id); + if (!track) return { error: 'Track not found' }; + const archiveSessions = options.archiveSessions === true; + // Raw terminals have no transcript row, but participate in track deletion. + for (const [sid, session] of activeSessions) { + if (session.trackId === id && session.isPlainTerminal) dbModule.setSessionAssignment(sid, track.projectId, id); + } + const result = projects.deleteTrack(id, { archiveSessions }); + if (result.error) return result; + const affected = new Set(result.sessionIds); + for (const [sid, session] of activeSessions) { + if (session.trackId !== id && !affected.has(sid)) continue; + session.trackId = null; + session.formerTrackName = track.name; + if (archiveSessions && !session.exited) { + session.stopRequested = true; + try { session.pty.kill(); } catch (error) { log.error('[delete-track] stop failed', error); } + } + } + return result; +})); +ipcMain.handle('get-projects-root', guarded(() => projects.projectsRoot())); +ipcMain.handle('get-project-git-status', guarded((id, opts) => projects.folderGitStatus(id, opts || {}))); +ipcMain.handle('get-project-git-info', guarded((id) => projects.projectGitInfo(id))); +ipcMain.handle('get-project-git-diff', guarded((id, folderPath, filePath) => projects.projectGitDiff(id, folderPath, filePath))); +ipcMain.handle('get-folder-git-status', guarded((folderPath) => projects.folderGitInfo(String(folderPath || '')))); +// The .env files a folder has, and the ones the dialog ticks by default, +// so a new worktree can be offered its repository's local environment. +ipcMain.handle('list-env-files', guarded((folderPath) => ({ + ok: true, + files: projects.listEnvFiles(String(folderPath || '')), + defaults: projects.defaultEnvSelection(String(folderPath || '')), +}))); +ipcMain.handle('save-project-brief', guarded((id, content) => projects.saveBrief(id, content))); +ipcMain.handle('create-project-file', guarded((id, name, content) => projects.createProjectFile(id, name, content))); +ipcMain.handle('add-project-files', guarded((id, sourcePaths) => projects.addProjectFiles(id, sourcePaths))); +ipcMain.handle('list-recent-project-files', guarded((id) => projects.listRecentProjectFiles(id))); +ipcMain.handle('get-project-plan', guarded((id) => projects.readProjectPlan(id))); +ipcMain.handle('set-plan-item', guarded((id, kind, line, done) => projects.setPlanItem(id, kind, line, done))); +ipcMain.handle('append-plan-item', guarded((id, kind, text) => projects.appendPlanItem(id, kind, text))); +ipcMain.handle('edit-plan-item', guarded((id, kind, line, text) => projects.editPlanItem(id, kind, line, text))); +ipcMain.handle('adopt-plan', guarded((id, filename, opts) => projects.adoptPlan(id, filename, opts || {}))); +ipcMain.handle('list-templates', guarded(() => projects.listTemplates())); + // --- IPC: get-projects --- ipcMain.handle('open-external', (_event, url) => { log.info('[open-external IPC]', url); if (/^https?:\/\//i.test(url)) return shell.openExternal(url); }); +// Reveal a folder in the OS file manager. Only existing directories, so a +// renderer value can never launch a file. +ipcMain.handle('open-path', async (_event, target) => { + try { + const resolved = path.resolve(String(target || '')); + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) return { error: 'Not a folder' }; + const err = await shell.openPath(resolved); + return err ? { error: err } : { ok: true }; + } catch (err) { + return { error: err.message }; + } +}); + // --- IPC: clipboard write --- // The renderer's navigator.clipboard.writeText is gated on focus/user-activation and // is flaky-to-dead on Linux/Wayland (Ozone). The main-process clipboard has no such @@ -383,15 +520,24 @@ ipcMain.on('mcp-diff-response', (_event, sessionId, diffId, action, editedConten resolvePendingDiff(sessionId, diffId, action, editedContent); }); -ipcMain.handle('read-file-for-panel', async (_event, filePath) => { +ipcMain.handle('resolve-terminal-files', (_event, references) => resolveTerminalFiles(references)); + +ipcMain.handle('open-file-externally', async (_event, filePath, projectRoot) => { try { - const content = fs.readFileSync(filePath, 'utf8'); - return { ok: true, content }; + return { ok: true, ...await openFileExternally(filePath, projectRoot, shell) }; } catch (err) { return { ok: false, error: err.message }; } }); +ipcMain.handle('read-file-for-panel', async (_event, filePath) => { + try { + return { ok: true, ...readPreviewFile(filePath) }; + } catch (err) { + return { ok: false, error: err.message, code: err.code }; + } +}); + ipcMain.handle('list-project-directory', async (_event, projectPath, relativePath) => { try { return { ok: true, entries: listProjectDirectory(projectPath, relativePath) }; @@ -400,11 +546,33 @@ ipcMain.handle('list-project-directory', async (_event, projectPath, relativePat } }); +ipcMain.handle('manage-project-entry', async (_event, projectPath, relativePath, action, newName) => { + try { + const result = await manageProjectEntry(projectPath, relativePath, action, newName, { + shell, + confirmTrash: async (filePath, isDirectory) => { + const destination = process.platform === 'win32' ? 'Recycle Bin' : 'Trash'; + const choice = await dialog.showMessageBox(mainWindow, { + type: 'question', + message: `Move "${path.basename(filePath)}" to the ${destination}?`, + detail: `${isDirectory ? 'The folder and its contents' : 'The file'} can be restored from the ${destination}. Open previews of this item will close; unsaved edits will be discarded.`, + buttons: ['Cancel', `Move to ${destination}`], defaultId: 0, cancelId: 0, + noLink: true, + }); + return choice.response === 1; + }, + }); + return { ok: true, ...result }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('read-project-file', async (_event, projectPath, relativePath) => { try { return { ok: true, ...readProjectFile(projectPath, relativePath) }; } catch (err) { - return { ok: false, error: err.message }; + return { ok: false, error: err.message, code: err.code }; } }); @@ -613,7 +781,7 @@ ipcMain.handle('get-codex-usage', async () => { // --- IPC: get-memories --- function folderToShortPath(folder) { - // Convert "-Users-home-dev-MyClaude" → "dev/MyClaude" + // Convert "-Users-me-dev-my-app" → "dev/my-app" const parts = folder.replace(/^-/, '').split('-'); const meaningful = parts.filter(Boolean); return meaningful.slice(-2).join('/'); @@ -659,7 +827,7 @@ ipcMain.handle('get-memories', () => { const projectPath = deriveProjectPath(folderPath, folder); if (projectPath && hiddenProjects.has(projectPath)) continue; - // Use same 2-deep short path as Sessions tab (e.g. "dev/MyClaude") + // Use same 2-deep short path as Sessions tab (e.g. "dev/my-app") // Splits on both separators — `cwd` is backslash-separated on Windows, // where splitting on '/' alone left the whole path as one segment. const shortName = projectPath @@ -782,6 +950,11 @@ ipcMain.handle('save-memory', (_event, filePath, content) => { }); // --- IPC: search --- +ipcMain.handle('search-session-ids', (_event, query, sessionIds) => { + if (typeof query !== 'string' || !Array.isArray(sessionIds) || sessionIds.some(id => typeof id !== 'string')) return []; + return dbModule.searchSessionIds(query, sessionIds); +}); + ipcMain.handle('search', (_event, type, query, titleOnly) => { return searchByType(type, query, 50, !!titleOnly); }); @@ -842,7 +1015,6 @@ ipcMain.handle('delete-setting', (_event, key) => { }); // --- Scheduled tasks --- -const scheduleIpc = require('./schedule-ipc'); const SETTING_DEFAULTS = { permissionMode: null, @@ -852,6 +1024,9 @@ const SETTING_DEFAULTS = { chrome: false, preLaunchCmd: '', addDirs: '', + // Claude's model and effort. Empty leaves the choice to claude. + model: '', + effort: '', visibleSessionCount: 5, sidebarWidth: 340, terminalTheme: 'switchboard', @@ -860,9 +1035,12 @@ const SETTING_DEFAULTS = { // Codex equivalents of the permission settings above. Kept separate because // the vocabularies do not map: codex has no permission modes, and Claude has // no sandbox policy. An empty value means "leave it to codex's own config". - codexSandbox: '', + // The app starts on workspace-write: a project's attached folders only reach + // codex under that sandbox, and read-only sessions cannot do the work. + codexSandbox: 'workspace-write', codexApproval: '', codexModel: '', + codexEffort: '', }; // --- Harness enablement --- @@ -888,6 +1066,10 @@ ipcMain.handle('get-harnesses', () => { .map(h => ({ id: h.id, label: h.label, enabled: !disabled.has(h.id) })); }); +// Codex's model catalog, for model suggestions and the per-model reasoning +// efforts in session and schedule settings. +ipcMain.handle('get-codex-models', () => getHarness('codex')?.readModelCatalog?.() || []); + ipcMain.handle('get-shell-profiles', () => { _shellProfiles = null; // refresh on each request return getShellProfiles(); @@ -906,6 +1088,8 @@ ipcMain.handle('get-effective-settings', (_event, projectPath) => effectiveSetti const taskManager = createTaskManager({ baseEnv: cleanPtyEnv, getShellProfile: (projectPath) => resolveShell(effectiveSettings(projectPath).shellProfile), + // A project worktree inherits its source repo's tasks.json. + resolveWorktreeParent: (projectPath) => projects.worktreeParentFor(projectPath), log, send: (channel, ...args) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...args); @@ -936,7 +1120,12 @@ ipcMain.handle('get-active-terminals', () => { const terminals = []; for (const [sessionId, session] of activeSessions) { if (!session.exited && session.isPlainTerminal) { - terminals.push({ sessionId, projectPath: session.projectPath }); + terminals.push({ + sessionId, + projectPath: session.projectPath, + projectId: session.projectId || null, + trackId: session.trackId || null, + }); } } return terminals; @@ -967,29 +1156,43 @@ ipcMain.handle('rename-session', (_event, sessionId, name) => { return { name: name || null }; }); -// --- IPC: archive-session --- +function readSessionViewerEntries(row) { + // The harness owns its transcript naming and its on-disk format. Normalize + // here so neither transcript consumer needs runtime-specific branches. + const jsonlPath = transcriptPath(row); + const harness = getHarness(row.runtime); + const content = fs.readFileSync(jsonlPath, 'utf-8'); + const entries = []; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { entries.push(JSON.parse(line)); } catch {} + } + return harness.toViewerEntries(entries); +} + ipcMain.handle('read-session-jsonl', (_event, sessionId) => { const row = getCachedSession(sessionId); if (!row) return { error: 'Session not found in cache' }; - // The harness owns its transcript naming — Claude uses .jsonl, - // others do not — so resolve through it rather than rebuilding the path here. - const jsonlPath = transcriptPath(row); - const harness = getHarness(row.runtime); try { - const content = fs.readFileSync(jsonlPath, 'utf-8'); - const entries = []; - for (const line of content.split('\n')) { - if (!line.trim()) continue; - try { entries.push(JSON.parse(line)); } catch {} - } - // Normalised here rather than in the renderer, so the viewer never has to - // know which CLI wrote the transcript it is showing. - return { entries: harness.toViewerEntries(entries) }; + return { entries: readSessionViewerEntries(row) }; } catch (err) { return { error: err.message }; } }); +ipcMain.handle('get-session-last-message', (_event, sessionId) => { + const row = getCachedSession(sessionId); + if (!row) return { error: 'Session not found in cache' }; + try { + const { text, truncated } = lastAssistantMessage(readSessionViewerEntries(row)); + return { text, truncated }; + } catch (err) { + return { error: err.message }; + } +}); + +// --- IPC: archive-session --- + ipcMain.handle('archive-session', (_event, sessionId, archived) => { const val = archived ? 1 : 0; setArchived(sessionId, val); @@ -1000,6 +1203,15 @@ ipcMain.handle('archive-session', (_event, sessionId, archived) => { ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, sessionOptions) => { if (!mainWindow) return { ok: false, error: 'no window' }; + const isPlainTerminal = sessionOptions?.type === 'terminal'; + if (shouldBlockArchivedSession({ + isNew, + isPlainTerminal, + archived: getMeta(sessionId)?.archived, + })) { + return { ok: false, error: 'Session is archived. Unarchive it before resuming.' }; + } + // Reattach to existing session if (activeSessions.has(sessionId)) { const session = activeSessions.get(sessionId); @@ -1030,8 +1242,6 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se return { ok: false, error: `project directory no longer exists: ${projectPath}` }; } - const isPlainTerminal = sessionOptions?.type === 'terminal'; - // A session that never wrote a transcript cannot be resumed — the CLI has no // record of the id, and asking it to resume one produces an error the user // can do nothing about ("No saved session found with ID ..."). Start it @@ -1048,6 +1258,39 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se log.info(`[open-terminal] ${sessionId} has no transcript; starting a new session instead of resuming`); } + // A session that belongs to a project gets the project folder and every + // attached folder as extra directories, so the brief loads wherever it + // starts and the agent can edit the repos. New sessions say which project + // in their options; resumed ones are looked up by their assignment. + let projectEnv = {}; + // A fork of a project session belongs to the same project and track, even + // when it is started from the Sessions tab where no project is in play. + if (!isPlainTerminal && sessionOptions?.forkFrom && !sessionOptions.projectId) { + const source = getMeta(sessionOptions.forkFrom); + if (source?.projectId) sessionOptions = { ...sessionOptions, projectId: source.projectId, trackId: source.trackId || null }; + } + if (!isPlainTerminal) { + const launchProjectId = sessionOptions?.projectId || getMeta(sessionId)?.projectId || null; + if (launchProjectId) { + try { + const ctx = projects.launchContext(launchProjectId, projectPath); + if (ctx && ctx.addDirs.length) { + sessionOptions = { ...(sessionOptions || {}), addDirs: projects.mergeAddDirs(sessionOptions?.addDirs, ctx.addDirs) }; + projectEnv = ctx.env; + } + // A project worktree is already the isolated checkout; asking Claude + // for another one on top of it would nest worktrees. + if (ctx?.worktree && sessionOptions?.worktree) { + sessionOptions = { ...sessionOptions }; + delete sessionOptions.worktree; + delete sessionOptions.worktreeName; + } + } catch (err) { + log.error('[projects] launch context failed', err); + } + } + } + // Which CLI drives this session. The cached row is authoritative for anything // that already exists on disk; the caller only gets to say for a brand-new // session, which has no row yet. @@ -1123,30 +1366,21 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se try { if (isPlainTerminal) { // Plain terminal: interactive login shell, no claude command - // Inject a shell function to override `claude` with a helpful message - const claudeShim = 'claude() { echo "\\033[33mTo start a Claude session, use the + button in the sidebar.\\033[0m"; return 1; }; export -f claude 2>/dev/null;'; + // Do not inherit Claude Code's nested-session marker if Switchboard was + // itself launched from Claude; this is the user's shell and `claude` + // should resolve normally from their profile/PATH. + const terminalEnv = { + ...cleanPtyEnv, + TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', + }; + delete terminalEnv.CLAUDECODE; ptyProcess = pty.spawn(shell, shellArgs(shell, undefined, shellExtraArgs), { name: 'xterm-256color', cols: 120, rows: 30, cwd: isWsl ? os.homedir() : projectPath, - env: { - ...cleanPtyEnv, - TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', - CLAUDECODE: '1', - // ZDOTDIR trick won't work reliably; instead inject via ENV (sh/bash) or precmd - ENV: claudeShim, - BASH_ENV: claudeShim, - }, + env: terminalEnv, }); - // For zsh, ENV/BASH_ENV don't apply — write the function after shell starts - setTimeout(() => { - if (!ptyProcess._isDisposed) { - try { - ptyProcess.write(claudeShim + ' clear\n'); - } catch {} - } - }, 300); } else { // Argv is built by the harness and quoted here, so a value can never be // spliced into the command line as shell syntax. @@ -1181,6 +1415,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se ...cleanPtyEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', + ...projectEnv, }; // A harness that cannot be told its session id up front gets to stamp the // environment instead, so its transcript can be recognised afterwards. @@ -1211,6 +1446,20 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se projectPath, firstResize: true, projectFolder, knownJsonlFiles, sessionSlug, isPlainTerminal, runtime: runtimeId, forkFrom: sessionOptions?.forkFrom || null, + // A plain terminal has no CLI to report a busy state, so one is derived + // from the shell's own OSC 133 prompt marks, or from the PTY's foreground + // process when the shell sends none. A harness session already has the + // OSC 0 / OSC 9;4 handlers below. + activity: isPlainTerminal ? createTerminalActivity({ + shellName: path.basename(shell), + // On Windows `pty.process` is the console title, not a process name. + canPollProcess: process.platform !== 'win32', + }) : null, + // Plain terminals have no transcript row to carry their project filing. + // Keep the launch context on the live PTY so renderer reloads can put a + // terminal started in an attached folder back into the same project/track. + projectId: sessionOptions?.projectId || null, + trackId: sessionOptions?.trackId || null, // Set for a harness whose real session id only appears once its transcript // does; cleared by resolvePendingLaunches when the transcript is matched. pendingLaunch: (harness?.needsIdDetection?.({ isNew: startFresh, options: sessionOptions })) ? { @@ -1224,10 +1473,49 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se mcpServer, _openedAt: Date.now(), }; activeSessions.set(sessionId, session); + if (session.activity) startTerminalActivitySweep(); + + // A session launched from a project is filed there. Recorded under whatever + // id the session has right now; resolvePendingLaunches moves it to the real + // id for a harness that only learns its id from the transcript. + if (startFresh && !isPlainTerminal && sessionOptions?.projectId) { + try { + const assignment = projects.recordLaunchAssignment(sessionId, sessionOptions); + if (assignment) { + session.projectId = assignment.projectId; + session.trackId = assignment.trackId; + // Started from a phase or a todo on the project page. + const item = sessionOptions.planItem; + if (item?.itemText) projects.recordPlanLink(assignment.projectId, item.file, item.itemText, sessionId, 'started'); + } + } catch (err) { + log.error('[projects] could not record launch assignment', err); + } + } + // Started by a schedule: the session remembers which, the schedule + // remembers the run. Folder schedules have no project, so this is separate + // from the assignment above. + if (startFresh && !isPlainTerminal && sessionOptions?.scheduleId) { + try { projects.recordScheduleRun(sessionOptions.scheduleId, sessionId); } catch (err) { + log.error('[schedule] could not record the run', err); + } + } + // A resumed project session is a project session too, for the plan watcher. + if (!isPlainTerminal && !session.projectId) { + const meta = getMeta(session.realSessionId || sessionId); + if (meta?.projectId) { session.projectId = meta.projectId; session.trackId = meta.trackId || null; } + } ptyProcess.onData(data => { const currentId = session.realSessionId || sessionId; + // Prompt marks for a plain terminal. Cheap on every chunk: the parse + // returns immediately unless the chunk actually contains an OSC 133. + if (session.activity) { + const busy = session.activity.feedData(data); + if (busy !== null) sendTerminalBusy(session, currentId, busy); + } + // Parse OSC sequences (title changes, progress, notifications, etc.) if (data.includes('\x1b]')) { const oscMatches = data.matchAll(/\x1b\](\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/g); @@ -1368,7 +1656,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se // A signal kill reports exitCode 0, so pass the signal and the // stop-session flag along rather than making it guess from the code. const stopRequested = !!session.stopRequested; - if (mainWindow && !mainWindow.isDestroyed()) { + if (!appIsQuitting && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('process-exited', realId, exitCode, signal, stopRequested); // If a fork/plan-accept transition re-keyed this session under realId // but the PTY exited before transition detection ran, also notify the @@ -1437,9 +1725,56 @@ ipcMain.on('close-terminal', (_event, sessionId) => { } }); +// --- Plain-terminal activity: working, or waiting at the prompt? --- + +/** One place to tell the renderer a terminal started or stopped working. */ +function sendTerminalBusy(session, sessionId, busy) { + session._cliBusy = busy; + log.debug(`[terminal-activity] session=${sessionId} → ${busy ? 'BUSY' : 'IDLE'} ${JSON.stringify(session.activity.state())}`); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('cli-busy-state', sessionId, busy); + } +} + +// One sweep for every tracked terminal rather than a timer each. Reading +// `pty.process` costs ~18us, and only a shell that sends no prompt marks is +// read at all, so 50 terminals stay well under a millisecond per sweep. +const TERMINAL_ACTIVITY_INTERVAL_MS = 500; +let terminalActivityTimer = null; + +function sweepTerminalActivity() { + let tracked = 0; + for (const [sessionId, session] of activeSessions) { + if (!session.activity || session.exited) continue; + tracked++; + let busy = null; + if (session.activity.needsPoll()) { + // A PTY that has just exited throws here rather than returning a name. + let name = null; + try { name = session.pty.process; } catch {} + busy = session.activity.feedProcess(name); + } + // Ticked either way: a silent command crosses the busy threshold with no + // output to settle on. + if (busy === null) busy = session.activity.tick(); + if (busy !== null) sendTerminalBusy(session, sessionId, busy); + } + if (tracked === 0) { + clearInterval(terminalActivityTimer); + terminalActivityTimer = null; + } +} + +/** Started when the first terminal appears, stopped when the last one goes. */ +function startTerminalActivitySweep() { + if (terminalActivityTimer) return; + terminalActivityTimer = setInterval(sweepTerminalActivity, TERMINAL_ACTIVITY_INTERVAL_MS); + terminalActivityTimer.unref?.(); +} + // Session transitions → session-transitions.js const sessionTransitions = require('./session-transitions'); -sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer }); +sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer, copySessionAssignment }); const { detectSessionTransitions } = sessionTransitions; // --- fs.watch on projects directory --- @@ -1482,7 +1817,8 @@ function startProjectsWatcher() { } if (changed) { - notifyRendererProjectsChanged(); + // A transcript folder moved: sessions only, not the projects themselves. + notifyRendererProjectsChanged('sessions'); } } @@ -1543,6 +1879,13 @@ function resolvePendingLaunches(candidatePaths) { session.pendingLaunch = null; activeSessions.delete(tempId); activeSessions.set(realId, session); + if (session.projectId) { + try { + moveSessionAssignment(tempId, realId); + rekeyPlanLinks(tempId, realId); + } catch (err) { log.error('[projects] assignment move failed', err); } + } + try { dbModule.rekeyScheduleSession(tempId, realId); } catch (err) { log.error('[schedule] rekey failed', err); } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('session-detected', tempId, realId); } @@ -1639,7 +1982,7 @@ function startHarnessWatchers() { try { refreshFolder(folder); } catch (err) { log.error('[harness-watch]', folder, err.message); } } const titlesChanged = refreshHarnessTitles(h); - if (folders.size || titlesChanged) notifyRendererProjectsChanged(); + if (folders.size || titlesChanged) notifyRendererProjectsChanged('sessions'); } try { @@ -1675,7 +2018,7 @@ function startHarnessWatchers() { if (titleTimer) clearTimeout(titleTimer); titleTimer = setTimeout(() => { titleTimer = null; - if (refreshHarnessTitles(h)) notifyRendererProjectsChanged(); + if (refreshHarnessTitles(h)) notifyRendererProjectsChanged('sessions'); }, 300); }); titleWatcher.on('error', (err) => log.error(`[harness-title-watch] ${h.id}:`, err.message)); @@ -1724,48 +2067,66 @@ if (!gotSingleInstanceLock) { } }); + +// --- Scheduled task ticker --- +// Main owns the clock: a renderer timer is throttled while the window is +// hidden, and a schedule has to fire at the minute it names. Each fire is one +// 'schedule-due' event; the renderer launches the session, because the +// terminal lives there. A schedule whose last session is still working is +// skipped by projects.dueSchedules. +let scheduleTickerStop = null; +// "Still working" is the CLI being busy, not the PTY being open: an +// interactive session sits at its prompt until someone closes it, and that +// must not stop the next run. +function isSessionBusy(sessionId) { + const session = activeSessions.get(sessionId); + return !!session && !session.exited && !!session._cliBusy; +} +function fireSchedules(ids, reason) { + if (!ids.length || !mainWindow || mainWindow.isDestroyed()) return; + for (const id of ids) { + const launch = projects.resolveScheduleLaunch(id); + if (launch.error) { log.warn(`[schedule] ${id}: ${launch.error}`); continue; } + log.info(`[schedule] ${reason}: ${launch.schedule.name}`); + mainWindow.webContents.send('schedule-due', launch); + } +} +function startScheduleTicker() { + if (scheduleTickerStop) return; + let interval = null; + const tick = () => { + try { fireSchedules(projects.dueSchedules(new Date(), isSessionBusy), 'due'); } catch (err) { + log.error('[schedule] tick failed', err); + } + }; + // Align to the minute so a 9:00 schedule fires at 9:00:00, not 9:00:37. + const first = setTimeout(() => { tick(); interval = setInterval(tick, 60 * 1000); }, (60 - new Date().getSeconds()) * 1000); + // Catch-up runs, for schedules that asked for it, once the renderer has had + // time to load. Missed while the app was closed means missed since the last + // run (projects.missedSchedules). + const catchUp = setTimeout(() => { + try { fireSchedules(projects.missedSchedules(new Date(), isSessionBusy), 'catch-up'); } catch (err) { + log.error('[schedule] catch-up failed', err); + } + }, 20 * 1000); + scheduleTickerStop = () => { clearTimeout(first); clearTimeout(catchUp); if (interval) clearInterval(interval); scheduleTickerStop = null; }; +} + app.whenReady().then(() => { + protocol.handle(PREVIEW_SCHEME, handlePreviewAssetRequest); buildMenu(); initializeHiddenProjectTimestamps(); createWindow(); startProjectsWatcher(); startHarnessWatchers(); - scheduleIpc.ensureScheduleCreatorCommand(); - - // Shared runCommand for cron scheduler and "run now" — takes argv, not a shell string - const { spawn: cpSpawn } = require('child_process'); - function runScheduleCommand(claudeArgv, cwd, name, onDone) { - const globalSettings = getSetting('global') || {}; - const profileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; - const profile = resolveShell(profileId); - const shell = profile.path; - const cmd = 'claude ' + quoteArgvForShell(shell, claudeArgv); - const args = shellArgs(shell, cmd, profile.args || []); - - log.info(`[schedule] Running: ${shell} ${args.join(' ')}`); - const child = cpSpawn(shell, args, { - cwd, - stdio: ['ignore', 'ignore', 'pipe'], - env: { ...cleanPtyEnv, FORCE_COLOR: '0' }, - }); - - let stderr = ''; - child.stderr.on('data', (data) => { stderr += data.toString(); }); - - child.on('exit', (code) => { - if (stderr.trim()) log.error(`[schedule] ${name} stderr:\n${stderr.trim()}`); - log.info(`[schedule] ${name} finished (exit ${code})`); - if (onDone) onDone(); - }); - - child.on('error', (err) => { - log.error(`[schedule] ${name} error:`, err.message); - if (onDone) onDone(); - }); - } - - scheduleIpc.init(log, runScheduleCommand); - startScheduler(log, runScheduleCommand); + // Retry legacy imports each launch; successful files are remembered in + // the DB, including deleted tasks. Missing folders can be picked up later. + // Then tick once a minute and launch due tasks through the renderer. + try { + const imported = projects.importLegacySchedules(scanSchedules(log)); + if (imported) log.info(`[schedule] Imported ${imported} schedule file(s) as folder schedules`); + } catch (err) { log.error('[schedule] legacy import failed', err); } + startScheduleTicker(); // Re-index search if FTS table was recreated (e.g. tokenizer config change) if (searchFtsRecreated) populateCacheViaWorker(); @@ -1788,6 +2149,10 @@ app.on('window-all-closed', () => { }); app.on('before-quit', () => { + // The renderer persists raw-terminal descriptors and scrollback during its + // unload. Do not report the shutdown kills as user-initiated terminal exits, + // or that renderer cleanup would delete the descriptors before restart. + appIsQuitting = true; // Shut down all MCP servers shutdownAllMcp(); taskManager.shutdown(); diff --git a/package-lock.json b/package-lock.json index be17e442..fda9004f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.35", "hasInstallScript": true, "dependencies": { + "@aiden0z/pptx-renderer": "1.2.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode-graphemes": "^0.4.0", @@ -43,6 +44,24 @@ "png2icons": "^2.0.1" } }, + "node_modules/@aiden0z/pptx-renderer": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.2.4.tgz", + "integrity": "sha512-/4X/BApiknc/JM4nAWQRp5zrnA2GKSMv5r1u3XLsMvySw9DTQjyXIKRtltRsmIYfoN32tHGFnrSlGok7nFS60w==", + "license": "Apache-2.0", + "dependencies": { + "echarts": "^6.0.0", + "jszip": "^3.10.1" + }, + "peerDependencies": { + "pdfjs-dist": ">=5 <7" + }, + "peerDependenciesMeta": { + "pdfjs-dist": { + "optional": true + } + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.1", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", @@ -3262,9 +3281,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/crc": { "version": "3.8.0", @@ -3673,6 +3690,16 @@ "dev": true, "license": "MIT" }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4838,6 +4865,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4961,6 +4994,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -5091,6 +5130,48 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5107,6 +5188,15 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5716,6 +5806,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5904,6 +6000,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -6217,6 +6319,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6779,6 +6887,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -7063,6 +7177,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/package.json b/package.json index d2f89ddc..9fdb8ae1 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,25 @@ "author": "Doctly ", "main": "main.js", "scripts": { - "start": "npm run bundle:codemirror && electron .", + "start": "npm run bundle && electron .", "test": "node --test", "electron": "electron .", "electron-dev": "SWITCHBOARD_DATA_DIR=\"$HOME/.switchboard-dev\" electron .", "bundle:codemirror": "esbuild public/codemirror-setup.js --bundle --outfile=public/codemirror-bundle.js --format=iife --platform=browser --minify", "generate-icons": "node scripts/generate-icons.js && node scripts/generate-dmg-background.js", - "build": "npm run bundle:codemirror && electron-builder", - "build:mac": "npm run bundle:codemirror && electron-builder --mac && node scripts/postinstall.js", - "build:mac:arm64": "npm run bundle:codemirror && electron-builder --mac --arm64 && node scripts/postinstall.js", - "build:win": "npm run bundle:codemirror && electron-builder --win", - "build:linux": "npm run bundle:codemirror && electron-builder --linux", - "release": "npm run bundle:codemirror && electron-builder --publish always", - "postinstall": "node scripts/postinstall.js" + "build": "npm run bundle && electron-builder", + "build:mac": "npm run bundle && electron-builder --mac && node scripts/postinstall.js", + "build:mac:arm64": "npm run bundle && electron-builder --mac --arm64 && node scripts/postinstall.js", + "build:win": "npm run bundle && electron-builder --win", + "build:linux": "npm run bundle && electron-builder --linux", + "release": "npm run bundle && electron-builder --publish always", + "postinstall": "node scripts/postinstall.js", + "bundle": "npm run bundle:codemirror && npm run bundle:pptx && npm run bundle:jsonc", + "bundle:pptx": "esbuild public/pptx-preview.js --bundle --outfile=public/pptx-preview-bundle.js --format=iife --platform=browser --minify", + "bundle:jsonc": "esbuild node_modules/jsonc-parser/lib/esm/main.js --bundle --outfile=public/jsonc-parser-bundle.js --format=iife --global-name=JsoncParser --platform=browser --minify" }, "dependencies": { + "@aiden0z/pptx-renderer": "1.2.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-unicode-graphemes": "^0.4.0", diff --git a/preload.js b/preload.js index 483154e6..420b643b 100644 --- a/preload.js +++ b/preload.js @@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('api', { saveMemory: (filePath, content) => ipcRenderer.invoke('save-memory', filePath, content), getProjects: (showArchived) => ipcRenderer.invoke('get-projects', showArchived), getHarnesses: () => ipcRenderer.invoke('get-harnesses'), + getCodexModels: () => ipcRenderer.invoke('get-codex-models'), getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'), getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'), stopSession: (id) => ipcRenderer.invoke('stop-session', id), @@ -22,16 +23,15 @@ contextBridge.exposeInMainWorld('api', { archiveSession: (id, archived) => ipcRenderer.invoke('archive-session', id, archived), openTerminal: (id, projectPath, isNew, sessionOptions) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions), search: (type, query, titleOnly) => ipcRenderer.invoke('search', type, query, titleOnly), + searchSessionIds: (query, sessionIds) => ipcRenderer.invoke('search-session-ids', query, sessionIds), readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId), + getSessionLastMessage: (sessionId) => ipcRenderer.invoke('get-session-last-message', sessionId), // Settings getSetting: (key) => ipcRenderer.invoke('get-setting', key), setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value), deleteSetting: (key) => ipcRenderer.invoke('delete-setting', key), getEffectiveSettings: (projectPath) => ipcRenderer.invoke('get-effective-settings', projectPath), - getScheduleCreatorCommand: () => ipcRenderer.invoke('get-schedule-creator-command'), - createScheduleSession: (projectPath) => ipcRenderer.invoke('create-schedule-session', projectPath), - runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath), getShellProfiles: () => ipcRenderer.invoke('get-shell-profiles'), // Project/worktree tasks @@ -46,7 +46,48 @@ contextBridge.exposeInMainWorld('api', { browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), + + // Projects (a piece of work with a folder on disk; see projects.js) + getProjectTree: (showArchived) => ipcRenderer.invoke('get-project-tree', showArchived), + createProject: (spec) => ipcRenderer.invoke('create-project', spec), + updateProject: (id, patch) => ipcRenderer.invoke('update-project', id, patch), + deleteProject: (id) => ipcRenderer.invoke('delete-project', id), + attachProjectFolder: (id, spec) => ipcRenderer.invoke('attach-project-folder', id, spec), + detachProjectFolder: (id, folderPath, opts) => ipcRenderer.invoke('detach-project-folder', id, folderPath, opts), + setSessionAssignment: (sessionId, projectId, trackId) => ipcRenderer.invoke('set-session-assignment', sessionId, projectId, trackId), + getProjectsRoot: () => ipcRenderer.invoke('get-projects-root'), + getProjectGitStatus: (id, opts) => ipcRenderer.invoke('get-project-git-status', id, opts), + getProjectGitInfo: (id) => ipcRenderer.invoke('get-project-git-info', id), + getProjectGitDiff: (id, folderPath, filePath) => ipcRenderer.invoke('get-project-git-diff', id, folderPath, filePath), + getFolderGitStatus: (folderPath) => ipcRenderer.invoke('get-folder-git-status', folderPath), + listEnvFiles: (folderPath) => ipcRenderer.invoke('list-env-files', folderPath), + saveProjectBrief: (id, content) => ipcRenderer.invoke('save-project-brief', id, content), + createProjectFile: (id, name, content) => ipcRenderer.invoke('create-project-file', id, name, content), + addProjectFiles: (id, sourcePaths) => ipcRenderer.invoke('add-project-files', id, sourcePaths), + listRecentProjectFiles: (id) => ipcRenderer.invoke('list-recent-project-files', id), + getProjectPlan: (id) => ipcRenderer.invoke('get-project-plan', id), + setPlanItem: (id, kind, line, done) => ipcRenderer.invoke('set-plan-item', id, kind, line, done), + appendPlanItem: (id, kind, text) => ipcRenderer.invoke('append-plan-item', id, kind, text), + editPlanItem: (id, kind, line, text) => ipcRenderer.invoke('edit-plan-item', id, kind, line, text), + adoptPlan: (id, filename, opts) => ipcRenderer.invoke('adopt-plan', id, filename, opts), + listTemplates: () => ipcRenderer.invoke('list-templates'), + onProjectPlanChanged: (callback) => { + ipcRenderer.on('project-plan-changed', (_event, projectId) => callback(projectId)); + }, + listSchedules: () => ipcRenderer.invoke('list-schedules'), + createSchedule: (spec) => ipcRenderer.invoke('create-schedule', spec), + updateSchedule: (id, patch) => ipcRenderer.invoke('update-schedule', id, patch), + deleteSchedule: (id) => ipcRenderer.invoke('delete-schedule', id), + resolveScheduleLaunch: (id) => ipcRenderer.invoke('resolve-schedule-launch', id), + getScheduleContext: (spec) => ipcRenderer.invoke('get-schedule-context', spec), + onScheduleDue: (callback) => { + ipcRenderer.on('schedule-due', (_event, launch) => callback(launch)); + }, + createTrack: (projectId, spec) => ipcRenderer.invoke('create-track', projectId, spec), + updateTrack: (id, patch) => ipcRenderer.invoke('update-track', id, patch), + deleteTrack: (id, options) => ipcRenderer.invoke('delete-track', id, options), openExternal: (url) => ipcRenderer.invoke('open-external', url), + openPath: (target) => ipcRenderer.invoke('open-path', target), writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text), // Send (fire-and-forget) @@ -80,7 +121,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('session-forked', (_event, oldId, newId) => callback(oldId, newId)); }, onProjectsChanged: (callback) => { - ipcRenderer.on('projects-changed', () => callback()); + ipcRenderer.on('projects-changed', (_e, reason) => callback(reason || 'project')); }, onStatusUpdate: (callback) => { ipcRenderer.on('status-update', (_event, text, type) => callback(text, type)); @@ -131,8 +172,11 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.send('mcp-diff-response', sessionId, diffId, action, editedContent); }, readFileForPanel: (filePath) => ipcRenderer.invoke('read-file-for-panel', filePath), + openFileExternally: (filePath, projectRoot) => ipcRenderer.invoke('open-file-externally', filePath, projectRoot), + resolveTerminalFiles: (references) => ipcRenderer.invoke('resolve-terminal-files', references), listProjectDirectory: (projectPath, relativePath) => ipcRenderer.invoke('list-project-directory', projectPath, relativePath), readProjectFile: (projectPath, relativePath) => ipcRenderer.invoke('read-project-file', projectPath, relativePath), + manageProjectEntry: (projectPath, relativePath, action, newName) => ipcRenderer.invoke('manage-project-entry', projectPath, relativePath, action, newName), saveFileForPanel: (filePath, content) => ipcRenderer.invoke('save-file-for-panel', filePath, content), watchFile: (filePath) => ipcRenderer.invoke('watch-file', filePath), unwatchFile: (filePath) => ipcRenderer.invoke('unwatch-file', filePath), diff --git a/preview-assets.js b/preview-assets.js new file mode 100644 index 00000000..0b3e4f0f --- /dev/null +++ b/preview-assets.js @@ -0,0 +1,77 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { randomBytes } = require('node:crypto'); + +const PREVIEW_SCHEME = 'switchboard-preview'; +const PREVIEW_SCHEMES = [{ + scheme: PREVIEW_SCHEME, + privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true }, +}]; +const MAX_ASSET_BYTES = 32 * 1024 * 1024; +const MIME_TYPES = { + '.html': 'text/html', '.htm': 'text/html', + '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', + '.json': 'application/json', '.csv': 'text/csv', + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', + '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', + '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', +}; +// A preview receives an unguessable URL for its asset folder, never a file:// +// URL or an IPC bridge. The handler only serves read-only web assets in it. +const rootsByToken = new Map(); +const tokensByRoot = new Map(); + +function within(root, target) { + const relative = path.relative(root, target); + return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative); +} + +function createPreviewAssetUrl(filePath, projectRoot = path.dirname(filePath)) { + const root = fs.realpathSync(projectRoot); + const file = fs.realpathSync(filePath); + if (!within(root, file)) throw new Error('Preview file is outside its asset folder'); + let token = tokensByRoot.get(root); + if (!token) { + token = randomBytes(24).toString('hex'); + tokensByRoot.set(root, token); + rootsByToken.set(token, root); + } + const relative = path.relative(root, file).split(path.sep).map(encodeURIComponent).join('/'); + return `${PREVIEW_SCHEME}://${token}/${relative}`; +} + +async function handlePreviewAssetRequest(request) { + const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }; + const fail = status => new Response(null, { status, headers }); + if (request.method !== 'GET' && request.method !== 'HEAD') return fail(405); + try { + const url = new URL(request.url); + const root = rootsByToken.get(url.hostname); + if (url.protocol !== `${PREVIEW_SCHEME}:` || !root || url.username || url.password || url.port) return fail(404); + const relative = decodeURIComponent(url.pathname.slice(1)); + // Do not expose dotfiles, repository metadata or hidden configuration. + if (relative.split(/[\\/]/).some(part => part.startsWith('.'))) return fail(403); + const candidate = path.resolve(root, relative); + if (!within(root, candidate)) return fail(403); + const resolved = await fs.promises.realpath(candidate); + if (!within(root, resolved) || path.relative(root, resolved).split(path.sep).some(part => part.startsWith('.'))) return fail(403); + const mimeType = MIME_TYPES[path.extname(resolved).toLowerCase()]; + if (!mimeType) return fail(403); + const stat = await fs.promises.stat(resolved); + if (!stat.isFile()) return fail(404); + if (stat.size > MAX_ASSET_BYTES) return fail(413); + headers['Content-Type'] = mimeType; + const body = request.method === 'HEAD' ? null : await fs.promises.readFile(resolved); + return new Response(body, { headers }); + } catch { + return fail(404); + } +} + +module.exports = { PREVIEW_SCHEME, PREVIEW_SCHEMES, MAX_ASSET_BYTES, createPreviewAssetUrl, handlePreviewAssetRequest }; diff --git a/project-files.js b/project-files.js index 5fda73ba..c7d754cd 100644 --- a/project-files.js +++ b/project-files.js @@ -1,7 +1,28 @@ const fs = require('fs'); const path = require('path'); +const { pathToFileURL } = require('url'); +const { createPreviewAssetUrl } = require('./preview-assets'); const MAX_PREVIEW_BYTES = 2 * 1024 * 1024; +const MAX_MEDIA_PREVIEW_BYTES = 32 * 1024 * 1024; +const IMAGE_MIME_TYPES = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif', +}; + +function previewType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (IMAGE_MIME_TYPES[ext]) return 'image'; + if (ext === '.pdf') return 'pdf'; + if (ext === '.pptx') return 'pptx'; + if (ext === '.html' || ext === '.htm') return 'html'; + return 'text'; +} + +function previewLimit(filePath) { + return ['image', 'pdf', 'pptx'].includes(previewType(filePath)) ? MAX_MEDIA_PREVIEW_BYTES : MAX_PREVIEW_BYTES; +} const BINARY_EXTENSIONS = new Set([ '.7z', '.a', '.avi', '.bin', '.bmp', '.class', '.db', '.dmg', '.dll', '.doc', '.docx', '.dylib', '.eot', '.exe', '.gif', '.gz', '.ico', '.jar', '.jpeg', @@ -40,7 +61,8 @@ function resolveProjectEntry(projectPath, relativePath = '') { } function isViewableFile(filePath, stat) { - if (!stat.isFile() || stat.size > MAX_PREVIEW_BYTES) return false; + if (!stat.isFile() || stat.size > previewLimit(filePath)) return false; + if (['image', 'pdf', 'pptx'].includes(previewType(filePath))) return true; if (BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) return false; if (!stat.size) return true; @@ -75,6 +97,7 @@ function listProjectDirectory(projectPath, relativePath = '') { type, size: stat.size, viewable, + previewType: viewable ? previewType(absolutePath) : null, }; }).sort((a, b) => { if (a.type === 'directory' && b.type !== 'directory') return -1; @@ -84,20 +107,53 @@ function listProjectDirectory(projectPath, relativePath = '') { } function readProjectFile(projectPath, relativePath) { - const { resolved } = resolveProjectEntry(projectPath, relativePath); - const stat = fs.lstatSync(resolved); - if (!isViewableFile(resolved, stat)) { - throw new Error(stat.size > MAX_PREVIEW_BYTES + const { root, resolved } = resolveProjectEntry(projectPath, relativePath); + return readPreviewFile(resolved, root); +} + +function readPreviewFile(filePath, projectRoot) { + const stat = fs.statSync(filePath); + if (!isViewableFile(filePath, stat)) { + const error = new Error(stat.size > previewLimit(filePath) ? 'File is too large to preview' : 'File type cannot be previewed'); + if (stat.isFile()) error.code = 'PREVIEW_UNAVAILABLE'; + throw error; + } + const type = previewType(filePath); + const result = { filePath, previewType: type, fileUrl: pathToFileURL(filePath).href }; + if (type === 'html') result.previewUrl = createPreviewAssetUrl(filePath, projectRoot); + if (type === 'pptx') result.previewUrl = createPreviewAssetUrl(path.join(__dirname, 'public', 'pptx-preview.html')); + if (type === 'image' || type === 'pdf' || type === 'pptx') { + const mimeType = type === 'pdf' ? 'application/pdf' + : type === 'pptx' ? 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + : IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()]; + return { ...result, mimeType, base64: fs.readFileSync(filePath).toString('base64') }; + } + return { ...result, content: fs.readFileSync(filePath, 'utf8') }; +} + +// Only called for a user opening a file, never for background preview reads. +async function openFileExternally(filePath, projectRoot, shell) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) throw new Error('File path must be absolute'); + let resolved = fs.realpathSync(filePath); + if (projectRoot != null) { + const { root } = resolveProjectEntry(projectRoot); + resolved = resolveProjectEntry(root, path.relative(root, resolved)).resolved; } - return { filePath: resolved, content: fs.readFileSync(resolved, 'utf8') }; + if (!fs.statSync(resolved).isFile()) throw new Error('Not a file'); + const error = await shell.openPath(resolved); + if (error) throw new Error(error); + return { filePath: resolved }; } module.exports = { MAX_PREVIEW_BYTES, + MAX_MEDIA_PREVIEW_BYTES, isViewableFile, listProjectDirectory, readProjectFile, + readPreviewFile, + openFileExternally, resolveProjectEntry, }; diff --git a/projects.js b/projects.js new file mode 100644 index 00000000..26c990ad --- /dev/null +++ b/projects.js @@ -0,0 +1,1805 @@ +// projects.js — Projects: a piece of work with a folder on disk. +// +// A project owns a root folder under the projects root (default ~/Switchboard), +// attaches zero or more folders (the cwds sessions run in), and files sessions +// under itself or under one of its tracks. The folder on disk carries the +// context: CLAUDE.md / AGENTS.md is the brief every session below it reads, +// plan.md holds the plan in whatever form the agent writes it, plan-tracker.md +// is the fixed-format checklist Switchboard reads for progress, and todos.md +// the follow-ups. +// +// Rows live in their own tables (db.js: projects, project_folders, tracks) and +// the session ↔ project link is session_meta.projectId / trackId. Nothing here +// is keyed on the `project:` settings blob, which is deleted when a folder +// is hidden. +// +// Like session-cache.js this is a singleton initialised with init(ctx) so tests +// can hand it a fake db and a fake session list. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const git = require('./git'); +const planParser = require('./public/plan-parser'); +const sessionConfig = require('./public/session-config'); + +const DEFAULT_ROOT_NAME = 'Switchboard'; +const REPOS_DIR = 'repos'; +const ADDED_FILES_DIR = 'added-files'; +const BRANCH_NAME_RE = /^(?!-)[A-Za-z0-9._\/-]+$/; +const SLUG_MAX = 60; +const FOLDER_MODES = new Set(['in-place', 'worktree']); +const PROJECT_STATUSES = new Set(['active', 'done']); +// A .env is a few lines; anything larger under that name is something else. +const ENV_FILE_MAX_BYTES = 1024 * 1024; +const ENV_SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor', '.venv', 'venv']); + +const scheduleTime = require('./public/schedule-time'); + +let db, log, buildProjectsFromCache, notifyRendererProjectsChanged, isHarnessId, plansDir; + +function init(ctx) { + db = ctx.db; + log = ctx.log || console; + buildProjectsFromCache = ctx.buildProjectsFromCache; + notifyRendererProjectsChanged = ctx.notifyRendererProjectsChanged || (() => {}); + // Which CLI ids a track may name. Main passes the harness registry; tests + // and callers that do not care accept anything. + isHarnessId = ctx.isHarnessId || (() => true); + // Where Claude Code keeps plan-mode plans (~/.claude/plans), for adopting one. + plansDir = ctx.plansDir || path.join(os.homedir(), '.claude', 'plans'); + // Project templates; tests point this at a temp folder or the bundled one. + templatesDir = ctx.templatesDir || null; +} + +// --- Root and slugs --- + +function expandHome(p) { + if (p === '~') return os.homedir(); + if (p.startsWith('~' + path.sep) || p.startsWith('~/')) return path.join(os.homedir(), p.slice(2)); + return p; +} + +/** Where new projects are created. A global-only setting, like hiddenProjects. */ +function projectsRoot() { + const global = (db.getSetting && db.getSetting('global')) || {}; + const configured = typeof global.projectsRoot === 'string' ? global.projectsRoot.trim() : ''; + return configured ? path.resolve(expandHome(configured)) : path.join(os.homedir(), DEFAULT_ROOT_NAME); +} + +/** Lowercase, runs of non [a-z0-9] become "-", trimmed, at most SLUG_MAX chars. */ +function slugify(name) { + let slug = String(name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (slug.length > SLUG_MAX) slug = slug.slice(0, SLUG_MAX).replace(/-+$/g, ''); + return slug; +} + +/** slugify(name), with -2, -3, … appended while a project already uses it. */ +function uniqueSlug(name) { + const base = slugify(name) || 'project'; + if (!db.getProjectBySlug(base)) return base; + for (let n = 2; ; n++) { + const candidate = `${base}-${n}`; + if (!db.getProjectBySlug(candidate)) return candidate; + } +} + +// --- Files written into a new project folder --- +// +// The brief uses absolute paths throughout, because a session may start in an +// attached folder rather than the project folder and "./plan.md" would then +// point at the wrong place. +// +// Everything Switchboard owns — the title, the project folder, the working +// rules and the attached-folder list — sits in one block between markers. It +// is rewritten whole on every sync, so a change to any of it reaches projects +// that already exist. Anything outside the markers is the user's and is never +// touched. + +const MANAGED_START = ''; +const MANAGED_END = ''; +const MANAGED_NOTE = ''; +const BRIEF_FILES = ['CLAUDE.md', 'AGENTS.md']; + +function foldersSection(folderPaths) { + const lines = ['## Attached folders']; + if (!folderPaths.length) { + lines.push('No folders are attached yet. Attach the repos this project works in from the project settings in Switchboard.'); + } else { + lines.push("These attached folders are part of the project's working context. Unless the user specifies otherwise, interpret their requests—including questions, explanations, investigations, reviews, planning, and changes—in the context of these folders. Inspect the relevant attached folders to understand the request and ground your response in their contents. The project folder holds shared plans, notes, drafts, and context; it is not the full scope of the work. The first time you work in an attached folder in a session, read its own instructions (CLAUDE.md or AGENTS.md at its root) and follow them there. Once read, they hold for the rest of the session; do not re-read them before each task."); + for (const p of folderPaths) lines.push(`- ${p}`); + } + return lines.join('\n') + '\n'; +} + +// The plan itself is the agent's to shape (plan.md). What Switchboard reads is +// the tracker (plan-tracker.md), whose format is fixed so progress can be +// parsed. Neither is read at the start of a session; the rules say to open +// them only when the user brings the plan or the todos up. None of these +// files is created up front: the agent makes them when it first needs them. +const PROJECT_FILES = ['plan.md', 'plan-tracker.md', 'todos.md', 'memory.md']; + +/** The one block Switchboard owns: title, folder, rules, attached folders. */ +function managedBlock(name, root, folderPaths = []) { + return `${MANAGED_START} +${MANAGED_NOTE} +# ${name} +Project folder: ${root} + +${briefRules(root)} +${foldersSection(folderPaths)}${MANAGED_END} +`; +} + +/** + * A new brief: the managed block, then whatever the template adds below it as + * the user's own text. Without a template the managed block is the whole file. + */ +function defaultBrief(name, root, folderPaths = [], header = null) { + const block = managedBlock(name, root, folderPaths); + return header ? `${block}\n${header}` : block; +} + +function briefRules(root) { + const plan = path.join(root, 'plan.md'); + const tracker = path.join(root, 'plan-tracker.md'); + const todos = path.join(root, 'todos.md'); + const memory = path.join(root, 'memory.md'); + return `## Working rules +- This folder is the project. Keep plans, notes and drafts here, not in the attached folders. +- When the user asks for a plan, write it to ${plan} in whatever form fits the work. +- Whenever you write or change a plan, also keep ${tracker} up to date: one + "## Phase N: title" heading per phase, each with "- [ ]" checkbox items under it. + Tick an item when it is finished and the heading's own checkbox when the whole + phase is done. Switchboard reads this file to show progress. +- Keep follow-ups and ideas in ${todos} as "- [ ]" checkbox lines. When asked to + add a todo, append it there. Tick a todo when it is done. Work that is already + a phase or an item in the tracker does not belong in the todos as well. +- Anything you want to remember across sessions goes in ${memory}. +- Read ${plan}, ${tracker} or ${todos} only when the user brings up the plan or + the todos. Not at the start of a session, and not again before each task. + What you have already read stays current until you change it. +- These files may not exist yet. Create them when you first need them. +`; +} + +function writeIfMissing(filePath, content) { + if (fs.existsSync(filePath)) return false; + fs.writeFileSync(filePath, content, 'utf8'); + return true; +} + +/** Only the brief is written up front; plan, tracker, todos and memory are the agent's to create. */ +function writeProjectFiles(root, name, folderPaths, header = null) { + const brief = defaultBrief(name, root, folderPaths, header); + for (const file of BRIEF_FILES) writeIfMissing(path.join(root, file), brief); +} + +// --- Templates --- +// +// A template is a folder under /templates// with a +// template.json ({ name, description, tracks }) and the files a new project +// starts with. Three are seeded from the app's own templates/ folder the +// first time the folder is missing; after that they are the user's to edit, +// and a new folder there is a new template with no code change. A template's +// CLAUDE.md is the top of the brief; the working rules and the folder block +// are still appended, so every project keeps the same contract. + +const BUNDLED_TEMPLATES_DIR = path.join(__dirname, 'templates'); +const TEMPLATE_META = 'template.json'; +const TEMPLATE_KIND_RE = /^[a-z0-9][a-z0-9-]*$/; +let templatesDir = null; + +function dataDir() { + return process.env.SWITCHBOARD_DATA_DIR ? path.resolve(process.env.SWITCHBOARD_DATA_DIR) : path.join(os.homedir(), '.switchboard'); +} + +function templatesRoot() { + return templatesDir || path.join(dataDir(), 'templates'); +} + +/** Copy the bundled templates in when the folder does not exist yet. Never overwrites. */ +function seedTemplates() { + const dir = templatesRoot(); + if (fs.existsSync(dir)) return false; + if (!fs.existsSync(BUNDLED_TEMPLATES_DIR)) return false; + try { + fs.cpSync(BUNDLED_TEMPLATES_DIR, dir, { recursive: true }); + return true; + } catch (err) { + log.error?.(`[projects] could not seed templates into ${dir}`, err); + return false; + } +} + +function readTemplate(kind) { + if (typeof kind !== 'string' || !TEMPLATE_KIND_RE.test(kind)) return null; + const dir = path.join(templatesRoot(), kind); + const metaPath = path.join(dir, TEMPLATE_META); + if (!fs.existsSync(metaPath)) return null; + let meta; + try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return null; } + const tracks = (Array.isArray(meta.tracks) ? meta.tracks : []) + .map(t => (typeof t === 'string' ? { name: t } : t)) + .filter(t => t && typeof t.name === 'string' && t.name.trim()) + .map(t => ({ name: t.name.trim(), cli: typeof t.cli === 'string' && t.cli ? t.cli : null })); + return { kind, dir, name: typeof meta.name === 'string' && meta.name.trim() ? meta.name.trim() : kind, description: typeof meta.description === 'string' ? meta.description : '', tracks }; +} + +function listTemplates() { + seedTemplates(); + const dir = templatesRoot(); + const templates = []; + if (fs.existsSync(dir)) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const template = readTemplate(entry.name); + if (template) templates.push({ kind: template.kind, name: template.name, description: template.description, tracks: template.tracks }); + } + } + templates.sort((a, b) => a.name.localeCompare(b.name)); + return { dir, templates }; +} + +function renderTemplateText(text, vars) { + return String(text).replace(/\{\{\s*(name|slug|root)\s*\}\}/g, (_m, key) => vars[key] ?? ''); +} + +/** + * Copy a template's files into a new project folder. Markdown files have + * {{name}}, {{slug}} and {{root}} filled in; template.json and the brief + * files are skipped (the brief is composed separately). Returns the brief + * header from the template's CLAUDE.md, or null. + */ +function applyTemplate(template, root, vars) { + let header = null; + const walk = (from, rel) => { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const childRel = rel ? path.join(rel, entry.name) : entry.name; + const source = path.join(from, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(path.join(root, childRel), { recursive: true }); + walk(source, childRel); + continue; + } + if (!entry.isFile()) continue; + if (!rel && entry.name === TEMPLATE_META) continue; + if (!rel && BRIEF_FILES.includes(entry.name)) { + if (entry.name === 'CLAUDE.md' || (entry.name === 'AGENTS.md' && !header)) { + header = renderTemplateText(fs.readFileSync(source, 'utf8'), vars).trimEnd() + '\n'; + } + continue; + } + const dest = path.join(root, childRel); + if (fs.existsSync(dest)) continue; + if (/\.md$/i.test(entry.name)) fs.writeFileSync(dest, renderTemplateText(fs.readFileSync(source, 'utf8'), vars), 'utf8'); + else fs.copyFileSync(source, dest); + } + }; + walk(template.dir, ''); + return header; +} + +/** + * Create one of the agent-owned files when the user opens it from the page + * before any session has. Only the four known names, only when missing. + */ +function createProjectFile(projectId, name, content) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + if (!PROJECT_FILES.includes(name)) return { error: `Not a project file: ${name}` }; + const filePath = path.join(project.root, name); + if (fs.existsSync(filePath)) return { ok: true, filePath, created: false }; + const label = name.replace(/\.md$/, '').replace(/-/g, ' '); + const text = typeof content === 'string' ? content : `# ${project.name} ${label}\n\n`; + try { fs.writeFileSync(filePath, text, 'utf8'); } catch (err) { return { error: err.message }; } + return { ok: true, filePath, created: true }; +} + +/** Files the user has dropped into this project's durable context folder. */ +function addedFilesAtRoot(root) { + const dirPath = path.join(root, ADDED_FILES_DIR); + let entries; + try { + if (!fs.lstatSync(dirPath).isDirectory()) return { dirPath, files: [] }; + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { entries = []; } + const files = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + const filePath = path.join(dirPath, entry.name); + let stat; + try { stat = fs.lstatSync(filePath); } catch { continue; } + files.push({ + name: entry.name, + relativePath: path.join(ADDED_FILES_DIR, entry.name), + type: 'file', + size: stat.size, + modified: stat.mtime.toISOString(), + }); + } + files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + return { dirPath, files }; +} + +// Bounds for the Overview's Recent Files walk, so a project folder that +// collects something huge cannot stall the page. +const RECENT_FILES_MAX_DEPTH = 6; +const RECENT_FILES_MAX_ENTRIES = 5000; + +/** + * The newest files in the project folder, by when each was created, so a plan + * edited every hour does not crowd out a file added today. Skips the briefs + * every project starts with, attached repositories under repos/, hidden + * entries and dependency folders. Symlinks are not followed. + */ +function listRecentProjectFiles(projectId, limit = 5) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const count = Math.max(1, Math.min(50, Number(limit) || 5)); + const root = project.root; + const found = []; + let visited = 0; + const walk = (rel, depth) => { + let entries; + try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (++visited > RECENT_FILES_MAX_ENTRIES) return; + if (entry.name.startsWith('.')) continue; + const childRel = rel ? path.join(rel, entry.name) : entry.name; + if (entry.isDirectory()) { + if ((!rel && entry.name === REPOS_DIR) || ENV_SKIP_DIRS.has(entry.name) || depth >= RECENT_FILES_MAX_DEPTH) continue; + walk(childRel, depth + 1); + } else if (entry.isFile() && !(!rel && BRIEF_FILES.includes(entry.name))) { + let stat; + try { stat = fs.lstatSync(path.join(root, childRel)); } catch { continue; } + // Filesystems that do not record creation time report 0; use mtime there. + const addedMs = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs; + found.push({ name: entry.name, relativePath: childRel, added: new Date(addedMs).toISOString(), size: stat.size, addedMs }); + } + } + }; + walk('', 0); + found.sort((a, b) => b.addedMs - a.addedMs || a.relativePath.localeCompare(b.relativePath)); + return { ok: true, files: found.slice(0, count).map(({ addedMs, ...file }) => file) }; +} + +function listAddedFiles(projectId) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + return { ok: true, ...addedFilesAtRoot(project.root) }; +} + +/** Pick a non-destructive destination such as "brief (2).pdf" on collisions. */ +function uniqueAddedFilePath(dirPath, name) { + const first = path.join(dirPath, name); + if (!fs.existsSync(first)) return first; + const ext = path.extname(name); + const stem = name.slice(0, name.length - ext.length); + for (let n = 2; ; n++) { + const candidate = path.join(dirPath, `${stem} (${n})${ext}`); + if (!fs.existsSync(candidate)) return candidate; + } +} + +/** + * Copy dropped files into /added-files without overwriting anything. + * Source paths come from Electron's webUtils.getPathForFile; main still + * validates every one before touching the project folder. + */ +async function addProjectFiles(projectId, sourcePaths) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + if (!Array.isArray(sourcePaths) || !sourcePaths.length) return { error: 'No files were dropped' }; + + const dirPath = path.join(project.root, ADDED_FILES_DIR); + const added = []; + const errors = []; + const seen = new Set(); + for (const raw of sourcePaths) { + const source = typeof raw === 'string' && path.isAbsolute(raw) ? path.resolve(raw) : ''; + if (!source) { + errors.push(`${path.basename(String(raw || 'File')) || 'File'}: Source path must be absolute`); + continue; + } + if (seen.has(source)) continue; + seen.add(source); + const label = path.basename(source) || String(raw || 'File'); + try { + const stat = await fs.promises.lstat(source); + if (!stat.isFile()) throw new Error('Only files can be added'); + await fs.promises.mkdir(dirPath, { recursive: true }); + const dirStat = await fs.promises.lstat(dirPath); + if (!dirStat.isDirectory()) throw new Error(`${ADDED_FILES_DIR} must be a regular folder`); + const [rootReal, dirReal] = await Promise.all([ + fs.promises.realpath(project.root), + fs.promises.realpath(dirPath), + ]); + if (path.dirname(dirReal) !== rootReal) throw new Error(`${ADDED_FILES_DIR} must be inside the project folder`); + const destination = uniqueAddedFilePath(dirPath, label); + await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL); + added.push(path.basename(destination)); + } catch (err) { + errors.push(`${label}: ${err.message}`); + } + } + if (added.length) notifyRendererProjectsChanged(); + return { ok: true, ...addedFilesAtRoot(project.root), added, errors }; +} + +/** + * Replace the managed block in one brief file. When the markers are missing — + * the user deleted them — the block goes back at the top, where a new brief + * puts it, and the user's own text stays below. + */ +function syncBriefFile(filePath, block) { + if (!fs.existsSync(filePath)) return false; + const text = fs.readFileSync(filePath, 'utf8'); + const start = text.indexOf(MANAGED_START); + const end = text.indexOf(MANAGED_END); + const next = start !== -1 && end !== -1 && end > start + ? text.slice(0, start) + block.trimEnd() + '\n' + text.slice(end + MANAGED_END.length).replace(/^\n+/, '') + : block.trimEnd() + '\n\n' + text.trimStart(); + if (next !== text) fs.writeFileSync(filePath, next, 'utf8'); + return true; +} + +/** + * Keep the managed block in CLAUDE.md and AGENTS.md current — the title, the + * project folder, the working rules and the attached-folder list. Generated + * instructions belong only in the project root, never in attached repos. + */ +async function syncProjectBrief(projectId) { + const project = db.getProject(projectId); + if (!project) return; + const folders = db.listProjectFolders(projectId); + const block = managedBlock(project.name, project.root, folders.map(f => f.path)); + for (const file of BRIEF_FILES) { + try { syncBriefFile(path.join(project.root, file), block); } catch (err) { + log.error?.(`[projects] could not update ${file} in ${project.root}`, err); + } + } +} + +/** + * Bring every project's brief up to date at startup, so a Switchboard upgrade + * that changes the working rules reaches projects that already exist. Each + * file is only written when its text actually changed, so this is normally a + * no-op. + */ +async function syncAllProjectBriefs() { + let projectList = []; + try { projectList = db.listProjects() || []; } catch (err) { + log.error?.('[projects] could not list projects to sync briefs', err); + return; + } + for (const project of projectList) { + try { await syncProjectBrief(project.id); } catch (err) { + log.error?.(`[projects] could not sync the brief for ${project.name}`, err); + } + } +} + +/** + * Save the brief from the project page. CLAUDE.md and AGENTS.md always carry + * the same text in the project root. The managed folder block is re-synced, which + * puts it back if the user deleted it. Returns the text as written. + */ +async function saveBrief(projectId, content) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + if (typeof content !== 'string') return { error: 'Brief must be text' }; + const text = content.endsWith('\n') || content === '' ? content : content + '\n'; + try { + for (const file of BRIEF_FILES) fs.writeFileSync(path.join(project.root, file), text, 'utf8'); + } catch (err) { + return { error: `Could not write the brief: ${err.message}` }; + } + await syncProjectBrief(projectId); + const saved = fs.readFileSync(path.join(project.root, 'CLAUDE.md'), 'utf8'); + return { ok: true, content: saved }; +} + +// --- Validation helpers --- + +function isDirectory(p) { + try { return fs.statSync(p).isDirectory(); } catch { return false; } +} + +function normalizeFolderSpec(spec) { + if (!spec || typeof spec !== 'object') return { error: 'Folder must be an object' }; + const folderPath = typeof spec.path === 'string' ? spec.path.trim() : ''; + if (!folderPath || !path.isAbsolute(folderPath)) return { error: 'Folder path must be absolute' }; + if (!isDirectory(folderPath)) return { error: `Not a directory: ${folderPath}` }; + const mode = spec.mode || 'in-place'; + if (!FOLDER_MODES.has(mode)) return { error: `Unknown folder mode: ${mode}` }; + // Only a worktree needs .env copied; an in-place folder already has its own. + const copyEnv = mode === 'worktree' && Array.isArray(spec.copyEnv) + ? spec.copyEnv.filter(name => typeof name === 'string') + : []; + return { path: path.resolve(folderPath), mode, branch: typeof spec.branch === 'string' ? spec.branch.trim() : '', copyEnv }; +} + +// realpath of the longest existing ancestor, with the rest joined back on. +// A session cwd may be a directory that no longer exists (a removed worktree), +// and on macOS /var is a symlink to /private/var, so comparing a resolved +// path against a realpath'd root would miss. +function realOrResolved(p) { + const resolved = path.resolve(p); + let current = resolved; + const tail = []; + for (;;) { + try { return path.join(fs.realpathSync(current), ...tail); } catch {} + const parent = path.dirname(current); + if (parent === current) return resolved; + tail.unshift(path.basename(current)); + current = parent; + } +} + +function isInside(child, parent) { + if (child === parent) return true; + return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep); +} + +// --- Tree node shape handed to the renderer --- + +function trackNode(row) { + return { + id: row.id, projectId: row.projectId, name: row.name, + cwd: row.cwd || null, cli: row.cli || null, status: row.status || 'active', + sortOrder: row.sortOrder || 0, created: row.created, + sessions: [], + }; +} + +/** A schedule row as the renderer sees it: booleans, nulls, and nothing it cannot use. */ +function scheduleNode(row) { + return { + id: row.id, name: row.name, + projectId: row.projectId || null, trackId: row.trackId || null, cwd: row.cwd || null, + prompt: row.prompt, + every: row.every, atHour: row.atHour ?? null, atMinute: row.atMinute ?? null, + weekday: row.weekday ?? null, cron: row.cron || null, + cli: row.cli || null, enabled: !!row.enabled, catchUp: !!row.catchUp, + sessionConfig: row.sessionConfig || {}, + sourceFile: row.sourceFile || null, + lastRunAt: row.lastRunAt || null, lastSessionId: row.lastSessionId || null, + created: row.created, + }; +} + +function projectNode(row, folderRows = [], trackRows = [], scheduleRows = []) { + const added = addedFilesAtRoot(row.root); + return { + id: row.id, name: row.name, slug: row.slug, root: row.root, + status: row.status || 'active', + sharedBranch: row.sharedBranch === undefined ? true : !!row.sharedBranch, + branchName: row.branchName || null, + defaultCwd: row.defaultCwd || null, + snoozedUntil: row.snoozedUntil || null, + snoozedAt: row.snoozedAt || null, + created: row.created, modified: row.modified, + lastActivity: null, sessionCount: 0, + addedFilesPath: added.dirPath, + addedFiles: added.files, + folders: folderRows.map(f => ({ + path: f.path, mode: f.mode || 'in-place', sourcePath: f.sourcePath || null, + branch: f.branch || null, sortOrder: f.sortOrder || 0, + })), + tracks: trackRows.map(trackNode), + schedules: scheduleRows.map(scheduleNode), + sessions: [], + }; +} + +function loadProjectNode(id) { + const row = db.getProject(id); + if (!row) return null; + return projectNode(row, db.listProjectFolders(id), db.listTracks(id), db.listSchedulesByProject(id)); +} + +// --- Create / update / delete --- + +async function createProject(spec) { + const name = typeof spec?.name === 'string' ? spec.name.trim() : ''; + if (!name) return { error: 'Project name is required' }; + + const folders = []; + for (const raw of (Array.isArray(spec.folders) ? spec.folders : [])) { + const normalized = normalizeFolderSpec(raw); + if (normalized.error) return { error: normalized.error }; + if (folders.some(f => f.path === normalized.path)) continue; + folders.push(normalized); + } + const branchName = typeof spec.branchName === 'string' && spec.branchName.trim() ? spec.branchName.trim() : null; + if (branchName && !BRANCH_NAME_RE.test(branchName)) return { error: `Invalid branch name: ${branchName}` }; + let template = null; + if (spec.template) { + template = readTemplate(spec.template); + if (!template) return { error: `Unknown template: ${spec.template}` }; + } + + const slug = uniqueSlug(name); + const rootDir = projectsRoot(); + const root = path.join(rootDir, slug); + if (fs.existsSync(root)) { + return { error: `A folder named "${slug}" already exists in ${rootDir}` }; + } + + // In-place folders are listed in the brief right away; worktree folders + // are added after the row exists, since their path is only known once the + // checkout is made. + const inPlace = folders.filter(f => f.mode === 'in-place'); + try { + fs.mkdirSync(root, { recursive: true }); + const header = template ? applyTemplate(template, root, { name, slug, root }) : null; + writeProjectFiles(root, name, inPlace.map(f => f.path), header); + } catch (err) { + return { error: `Could not create ${root}: ${err.message}` }; + } + + const now = new Date().toISOString(); + const row = { + id: crypto.randomUUID(), name, slug, root, + status: 'active', + sharedBranch: spec.sharedBranch === undefined ? true : !!spec.sharedBranch, + branchName, + created: now, modified: now, + }; + db.insertProject(row); + inPlace.forEach((f, i) => db.upsertProjectFolder({ projectId: row.id, path: f.path, mode: 'in-place', sortOrder: i })); + // A template's tracks start in the project folder and inherit the default CLI + // unless the template names one. + if (template) { + template.tracks.forEach((t, i) => db.insertTrack({ + id: crypto.randomUUID(), projectId: row.id, name: t.name, cwd: null, + cli: t.cli && isHarnessId(t.cli) ? t.cli : null, status: 'active', sortOrder: i, created: now, + })); + } + + const errors = []; + for (const f of folders.filter(f => f.mode === 'worktree')) { + const result = await attachWorktree(db.getProject(row.id), { path: f.path, branch: f.branch, copyEnv: f.copyEnv }, { notify: false }); + if (result.error) errors.push(`${f.path}: ${result.error}`); + } + + log.info?.(`[projects] created "${name}" at ${root}`); + refreshPlanWatchers(); + notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(row.id), errors }; +} + +function updateProject(id, patch) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + const clean = {}; + if (typeof patch?.name === 'string') { + const name = patch.name.trim(); + if (!name) return { error: 'Project name is required' }; + clean.name = name; + } + if (patch?.status !== undefined) { + if (!PROJECT_STATUSES.has(patch.status)) return { error: `Unknown status: ${patch.status}` }; + clean.status = patch.status; + } + if (patch?.sharedBranch !== undefined) clean.sharedBranch = !!patch.sharedBranch; + if (patch?.branchName !== undefined) { + clean.branchName = typeof patch.branchName === 'string' && patch.branchName.trim() ? patch.branchName.trim() : null; + } + if (patch?.defaultCwd !== undefined) { + const cwd = normalizeCwdWithinProject(row, patch.defaultCwd); + if (cwd.error) return { error: cwd.error }; + clean.defaultCwd = cwd.cwd; + } + if (patch?.snoozedUntil !== undefined) { + const snooze = normalizeSnooze(row, patch.snoozedUntil, clean.status || row.status); + if (snooze.error) return { error: snooze.error }; + Object.assign(clean, snooze); + } else if (clean.status === 'done' && row.snoozedUntil) { + // Finishing a project ends its snooze; Done is not a place to wake up into. + clean.snoozedUntil = null; + clean.snoozedAt = null; + } + if (!Object.keys(clean).length) return { ok: true, project: loadProjectNode(id) }; + clean.modified = new Date().toISOString(); + db.updateProject(id, clean); + // The brief's title comes from the project name, so a rename rewrites it. + if (clean.name) syncProjectBrief(id).catch(err => log.error?.('[projects] could not retitle the brief', err)); + notifyRendererProjectsChanged(); + const result = { ok: true, project: loadProjectNode(id) }; + // Finishing a project is the moment to offer removing its worktrees; the + // renderer asks, then detaches each one with removeWorktree. + if (clean.status === 'done') { + result.worktrees = db.listProjectFolders(id).filter(f => f.mode === 'worktree').map(f => f.path); + } + return result; +} + +/** + * A snooze is a wake time in the future, or null to wake now. Snoozing again + * to the same instant keeps the original snoozedAt, so a repeated click does + * not churn the row. Only visibility changes: sessions keep running. + */ +function normalizeSnooze(row, value, status) { + if (value === null || value === '') return { snoozedUntil: null, snoozedAt: null }; + if (typeof value !== 'string') return { error: 'Wake time must be an ISO date' }; + const wake = Date.parse(value); + if (!Number.isFinite(wake)) return { error: 'Wake time must be an ISO date' }; + if (wake <= Date.now()) return { error: 'Wake time must be in the future' }; + if (status === 'done') return { error: 'A finished project cannot be snoozed' }; + const iso = new Date(wake).toISOString(); + const sameWake = row.snoozedUntil && Date.parse(row.snoozedUntil) === wake && row.snoozedAt; + return { snoozedUntil: iso, snoozedAt: sameWake ? row.snoozedAt : new Date().toISOString() }; +} + +/** Rows only. The folder on disk and every session stay. */ +function deleteProject(id) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + db.deleteProject(id); + log.info?.(`[projects] removed "${row.name}" (folder kept at ${row.root})`); + refreshPlanWatchers(); + notifyRendererProjectsChanged(); + return { ok: true, root: row.root }; +} + +async function attachFolder(id, spec) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + const normalized = normalizeFolderSpec(spec); + if (normalized.error) return { error: normalized.error }; + if (normalized.mode === 'worktree') return attachWorktree(row, normalized); + const existing = db.listProjectFolders(id); + if (existing.some(f => f.path === normalized.path)) return { ok: true, project: loadProjectNode(id) }; + const sortOrder = existing.reduce((max, f) => Math.max(max, f.sortOrder || 0), -1) + 1; + db.upsertProjectFolder({ projectId: id, path: normalized.path, mode: 'in-place', sortOrder }); + db.updateProject(id, { modified: new Date().toISOString() }); + await syncProjectBrief(id); + notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(id) }; +} + +// --- .env files --- +// +// git worktree add checks out tracked files only, so a repository's .env stays +// behind in the source checkout and the new worktree cannot run the app. The +// new project dialog lists what it found and the user picks; nothing is copied +// unless a name was asked for. + +/** A sample file is listed but never picked by default: it is usually tracked already. */ +function isEnvSample(name) { + return /^\.env\.(example|sample|template|dist)$/i.test(path.basename(name)); +} + +/** + * The .env files recursively within a folder: `.env` and `.env.*`, sorted, files + * only, as relative paths. Hidden directories, dependency folders and symlinks are skipped. + * Big files are left out — a .env is a few lines, and anything large + * under that name is something else. + */ +function listEnvFiles(dir) { + const root = typeof dir === 'string' ? path.resolve(dir.trim()) : ''; + if (!root || !isDirectory(root)) return []; + const names = []; + const pending = ['']; + while (pending.length) { + const relativeDir = pending.pop(); + let entries; + try { entries = fs.readdirSync(path.join(root, relativeDir), { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + const relativePath = path.join(relativeDir, entry.name); + if (entry.isDirectory()) { + if (!entry.name.startsWith('.') && !ENV_SKIP_DIRS.has(entry.name)) pending.push(relativePath); + continue; + } + // Do not follow symlinks, including links to directories outside the repo. + if (!entry.isFile()) continue; + if (entry.name !== '.env' && !entry.name.startsWith('.env.')) continue; + try { if (fs.statSync(path.join(root, relativePath)).size > ENV_FILE_MAX_BYTES) continue; } catch { continue; } + names.push(relativePath); + } + } + names.sort(); + return names; +} + +/** The names the dialog ticks for the user: everything but the samples. */ +function defaultEnvSelection(dir) { + return listEnvFiles(dir).filter(name => !isEnvSample(name)); +} + +/** + * Copy the named .env files from one folder to another. A name the source + * does not actually have is ignored, so the renderer can never name a path of + * its own, and an existing destination is never overwritten. + */ +function copyEnvFiles(source, target, names) { + const wanted = Array.isArray(names) ? names : []; + if (!wanted.length) return { copied: [], skipped: [] }; + const available = new Set(listEnvFiles(source)); + const copied = []; + const skipped = []; + for (const name of wanted) { + if (!available.has(name)) { skipped.push(name); continue; } + const dest = path.join(target, name); + if (fs.existsSync(dest)) { skipped.push(name); continue; } + try { + // Create missing parents, but never copy through a destination symlink. + let parent = target; + for (const part of name.split(path.sep).slice(0, -1)) { + parent = path.join(parent, part); + try { fs.mkdirSync(parent); } catch (err) { if (err.code !== 'EEXIST') throw err; } + const stat = fs.lstatSync(parent); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Unsafe destination directory'); + } + fs.copyFileSync(path.join(source, name), dest, fs.constants.COPYFILE_EXCL); + fs.chmodSync(dest, fs.statSync(path.join(source, name)).mode & 0o777); + copied.push(name); + } catch (err) { + log.error?.(`[projects] could not copy ${name} into ${target}`, err); + skipped.push(name); + } + } + return { copied, skipped }; +} + +/** The branch a project's worktrees use: one shared name, or per folder. */ +function worktreeBranchFor(project, requested) { + if (project.sharedBranch === undefined || project.sharedBranch) return project.branchName || project.slug; + return (typeof requested === 'string' && requested.trim()) ? requested.trim() : project.slug; +} + +/** + * Check a repository out under /repos/ on the project's branch + * and attach that checkout. Git creates the branch and worktree registration; + * Switchboard writes no instruction files or ignore rules in the repository. + */ +async function attachWorktree(project, spec, { notify = true } = {}) { + const source = typeof spec?.path === 'string' ? path.resolve(spec.path.trim()) : ''; + if (!source || !isDirectory(source)) return { error: `Not a directory: ${source || spec?.path}` }; + let top; + try { top = await git.repoRoot(source); } catch { return { error: `${source} is not inside a git repository` }; } + if (realOrResolved(top) !== realOrResolved(source)) { + return { error: `${source} is not the root of a git repository (${top} is)` }; + } + const branch = worktreeBranchFor(project, spec.branch); + if (!BRANCH_NAME_RE.test(branch)) return { error: `Invalid branch name: ${branch}` }; + + const existing = db.listProjectFolders(project.id); + if (existing.some(f => f.sourcePath === source)) return { error: 'That repository is already attached on a branch' }; + const target = path.join(project.root, REPOS_DIR, path.basename(source)); + if (fs.existsSync(target)) return { error: `${target} already exists` }; + if (existing.some(f => f.path === target)) return { error: 'That folder is already attached' }; + + try { + fs.mkdirSync(path.join(project.root, REPOS_DIR), { recursive: true }); + await git.worktreeAdd(source, target, branch); + } catch (err) { + return { error: `git worktree add failed: ${err.message}` }; + } + + // The checkout has tracked files only. Bring the .env files the user picked + // across so a session in the worktree can run the app. Never fatal: the + // worktree is attached either way. + const env = copyEnvFiles(source, target, spec.copyEnv); + if (env.copied.length) log.info?.(`[projects] copied ${env.copied.join(', ')} into ${target}`); + + const sortOrder = existing.reduce((max, f) => Math.max(max, f.sortOrder || 0), -1) + 1; + db.upsertProjectFolder({ projectId: project.id, path: target, mode: 'worktree', sourcePath: source, branch, sortOrder }); + db.updateProject(project.id, { modified: new Date().toISOString() }); + await syncProjectBrief(project.id); + log.info?.(`[projects] worktree ${target} on ${branch} from ${source}`); + if (notify) notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(project.id), folder: { path: target, mode: 'worktree', sourcePath: source, branch }, copiedEnv: env.copied }; +} + +/** + * Detach a folder. For a worktree folder, removeWorktree also deletes the + * checkout (never the branch); a dirty checkout is refused with dirty: true + * unless force is set, so the caller can ask before losing changes. + */ +async function detachFolder(id, folderPath, opts = {}) { + const row = db.getProject(id); + if (!row) return { error: 'Project not found' }; + const folder = db.listProjectFolders(id).find(f => f.path === folderPath) || null; + let worktreeRemoved = false; + if (folder?.mode === 'worktree') { + if (opts.removeWorktree && fs.existsSync(folder.path)) { + try { + await git.worktreeRemove(folder.sourcePath, folder.path, { force: !!opts.force }); + worktreeRemoved = true; + } catch (err) { + return { error: `Could not remove the worktree: ${err.message}`, dirty: git.isDirtyWorktreeError(err) }; + } + } + } + db.deleteProjectFolder(id, folderPath); + // A default or track cwd that pointed into the detached folder falls back + // to the project folder rather than a place the project no longer works in. + if (row.defaultCwd && isInside(realOrResolved(row.defaultCwd), realOrResolved(folderPath))) { + db.updateProject(id, { defaultCwd: null }); + } + for (const track of db.listTracks(id)) { + if (track.cwd && isInside(realOrResolved(track.cwd), realOrResolved(folderPath))) db.updateTrack(track.id, { cwd: null }); + } + db.updateProject(id, { modified: new Date().toISOString() }); + await syncProjectBrief(id); + notifyRendererProjectsChanged(); + return { ok: true, project: loadProjectNode(id), worktreeRemoved }; +} + +/** Source repository of a project worktree folder, for task inheritance. */ +function worktreeParentFor(folderPath) { + if (!folderPath) return null; + const folder = db.listAllProjectFolders().find(f => f.mode === 'worktree' && f.path === folderPath); + return folder?.sourcePath || null; +} + +// --- Launch context --- + +/** + * Extra directories for a session that belongs to a project: the project + * folder and every attached folder, except the cwd itself and anything + * already under it. A parent of the cwd (the project folder above a worktree, + * a repo above a track that starts in one of its subfolders) is added too: + * the CLI only edits under the cwd unless told otherwise, and the plan and + * todos live in the project folder. Claude loads CLAUDE.md from added + * directories when CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD is set, so a + * session started in a repo still gets the brief and a session started in the + * project folder sees each repo's instructions. + */ +function launchContext(projectId, cwd) { + const project = db.getProject(projectId); + if (!project) return null; + const real = realOrResolved(cwd || project.root); + const folders = db.listProjectFolders(projectId); + const candidates = [project.root, ...folders.map(f => f.path)]; + const addDirs = []; + for (const p of candidates) { + const realP = realOrResolved(p); + if (realP === real || isInside(realP, real)) continue; + if (!addDirs.includes(p)) addDirs.push(p); + } + // Starting inside a project worktree: the caller must not ask the CLI for + // another worktree on top of it. + const worktree = folders.some(f => f.mode === 'worktree' && isInside(real, realOrResolved(f.path))); + return { + addDirs, + env: addDirs.length ? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1' } : {}, + worktree, + }; +} + +/** addDirs travels as a comma-separated string (see harnesses buildLaunchArgs). */ +function mergeAddDirs(existing, extra) { + const list = String(existing || '').split(',').map(s => s.trim()).filter(Boolean); + for (const dir of extra || []) if (!list.includes(dir)) list.push(dir); + return list.join(','); +} + +// --- Tracks --- + +/** + * Where sessions start, for a project's default or a track. It has to be the + * project folder, an attached folder, or somewhere inside one of them; null + * means "not set" (the project folder, or for a track the project's default). + * Returns { cwd } or { error }. + */ +function normalizeCwdWithinProject(project, cwd) { + if (cwd === undefined || cwd === null || cwd === '') return { cwd: null }; + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return { error: 'Folder must be an absolute path' }; + const resolved = path.resolve(cwd); + if (!isDirectory(resolved)) return { error: `Not a directory: ${resolved}` }; + const real = realOrResolved(resolved); + const allowed = [project.root, ...db.listProjectFolders(project.id).map(f => f.path)]; + if (!allowed.some(p => isInside(real, realOrResolved(p)))) { + return { error: 'Folder must be the project folder, an attached folder, or inside one of them' }; + } + return { cwd: resolved }; +} +const normalizeTrackCwd = normalizeCwdWithinProject; + +function normalizeTrackCli(cli) { + if (cli === undefined || cli === null || cli === '') return { cli: null }; + if (typeof cli !== 'string' || !isHarnessId(cli)) return { error: `Unknown CLI: ${cli}` }; + return { cli }; +} + +function createTrack(projectId, spec) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = typeof spec?.name === 'string' ? spec.name.trim() : ''; + if (!name) return { error: 'Track name is required' }; + const cwd = normalizeTrackCwd(project, spec.cwd); + if (cwd.error) return { error: cwd.error }; + const cli = normalizeTrackCli(spec.cli); + if (cli.error) return { error: cli.error }; + const existing = db.listTracks(projectId); + const sortOrder = existing.reduce((max, t) => Math.max(max, t.sortOrder || 0), -1) + 1; + const row = { + id: crypto.randomUUID(), projectId, name, cwd: cwd.cwd, cli: cli.cli, + status: 'active', sortOrder, created: new Date().toISOString(), + }; + db.insertTrack(row); + db.updateProject(projectId, { modified: row.created }); + notifyRendererProjectsChanged(); + return { ok: true, track: trackNode(row), project: loadProjectNode(projectId) }; +} + +function updateTrack(id, patch) { + const track = db.getTrack(id); + if (!track) return { error: 'Track not found' }; + const project = db.getProject(track.projectId); + if (!project) return { error: 'Project not found' }; + const clean = {}; + if (typeof patch?.name === 'string') { + const name = patch.name.trim(); + if (!name) return { error: 'Track name is required' }; + clean.name = name; + } + if (patch?.cwd !== undefined) { + const cwd = normalizeTrackCwd(project, patch.cwd); + if (cwd.error) return { error: cwd.error }; + clean.cwd = cwd.cwd; + } + if (patch?.cli !== undefined) { + const cli = normalizeTrackCli(patch.cli); + if (cli.error) return { error: cli.error }; + clean.cli = cli.cli; + } + if (patch?.status !== undefined) { + if (!PROJECT_STATUSES.has(patch.status)) return { error: `Unknown status: ${patch.status}` }; + clean.status = patch.status; + } + if (patch?.sortOrder !== undefined) clean.sortOrder = Number(patch.sortOrder) || 0; + if (!Object.keys(clean).length) return { ok: true, track: trackNode(track) }; + db.updateTrack(id, clean); + db.updateProject(track.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true, track: trackNode(db.getTrack(id)) }; +} + +/** Sessions keep their project and a snapshot of the deleted track's name. */ +function deleteTrack(id, { archiveSessions = false } = {}) { + const track = db.getTrack(id); + if (!track) return { error: 'Track not found' }; + const sessionIds = db.deleteTrack(id, { archiveSessions }) || []; + db.updateProject(track.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true, projectId: track.projectId, formerTrackName: track.name, sessionIds }; +} + +// --- Schedules --- +// A saved prompt plus a time. When it fires, the renderer starts an ordinary +// session with the prompt as its first message (initialPrompt) and the +// schedule's id on the launch options, so the session row can show where it +// came from. A row lives in exactly one place: with a projectId it belongs to +// the project view (under trackId, null = General) and runs in the track's +// cwd; without one it is a folder schedule listed on the Sessions tab under +// `cwd`. Timing is fields, not cron, except for rows imported from the old +// schedule-*.md files (every = 'cron'). + +const SCHEDULE_TIMING_KEYS = ['every', 'atHour', 'atMinute', 'weekday', 'cron']; + +/** Validate the timing fields of a spec. Returns { timing } or { error }. */ +function normalizeScheduleTiming(spec, existing = null) { + const every = spec.every === undefined ? existing?.every : spec.every; + if (!scheduleTime.EVERY_VALUES.has(every)) return { error: 'Pick when the task runs' }; + const pick = (key, lo, hi, fallback) => { + const raw = spec[key] === undefined ? existing?.[key] : spec[key]; + if (raw === null || raw === undefined || raw === '') return fallback; + const n = Number(raw); + if (!Number.isInteger(n) || n < lo || n > hi) return { error: `${key} must be between ${lo} and ${hi}` }; + return n; + }; + const timing = { every, atHour: null, atMinute: null, weekday: null, cron: null }; + if (every === 'cron') { + // Only an imported file gets here, and the dialog never edits the cron: + // picking a preset replaces it. Keep whatever the row already has. + const cron = spec.cron === undefined ? existing?.cron : spec.cron; + if (typeof cron !== 'string' || cron.trim().split(/\s+/).length !== 5) return { error: 'A cron schedule needs five fields' }; + timing.cron = cron.trim(); + return { timing }; + } + if (every === 'hour' || every === 'day' || every === 'weekdays' || every === 'week') { + const m = pick('atMinute', 0, 59, 0); + if (m?.error) return m; + timing.atMinute = m; + } + if (every === 'day' || every === 'weekdays' || every === 'week') { + const h = pick('atHour', 0, 23, 9); + if (h?.error) return h; + timing.atHour = h; + } + if (every === 'week') { + const d = pick('weekday', 0, 6, 1); + if (d?.error) return d; + timing.weekday = d; + } + return { timing }; +} + +/** Where a schedule belongs: { projectId, trackId, cwd } or { error }. */ +function normalizeSchedulePlace(spec) { + const projectId = spec.projectId || null; + if (projectId) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + let trackId = spec.trackId || null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== projectId) return { error: 'Track not found in that project' }; + } + return { projectId, trackId, cwd: null }; + } + const cwd = typeof spec.cwd === 'string' ? spec.cwd.trim() : ''; + if (!cwd || !path.isAbsolute(cwd)) return { error: 'A folder schedule needs a folder' }; + if (!isDirectory(cwd)) return { error: `Not a directory: ${cwd}` }; + return { projectId: null, trackId: null, cwd: path.resolve(cwd) }; +} + +function listSchedules() { + return db.listSchedules().map(scheduleNode); +} + +function normalizeScheduleConfig(value) { + try { + const config = sessionConfig.normalizeByCli(value); + for (const runtime of Object.keys(config)) { + if (!isHarnessId(runtime)) return { error: `Unknown CLI: ${runtime}` }; + } + return { config }; + } catch (err) { return { error: err.message }; } +} + +function createSchedule(spec) { + const name = typeof spec?.name === 'string' ? spec.name.trim() : ''; + if (!name) return { error: 'Give the task a name' }; + const prompt = typeof spec?.prompt === 'string' ? spec.prompt.trim() : ''; + if (!prompt) return { error: 'The task needs a prompt' }; + const place = normalizeSchedulePlace(spec || {}); + if (place.error) return { error: place.error }; + const timing = normalizeScheduleTiming(spec || {}); + if (timing.error) return { error: timing.error }; + const cli = normalizeTrackCli(spec.cli); + if (cli.error) return { error: cli.error }; + const config = normalizeScheduleConfig(spec.sessionConfig === undefined ? {} : spec.sessionConfig); + if (config.error) return config; + const row = { + id: crypto.randomUUID(), name, ...place, prompt, ...timing.timing, cli: cli.cli, + sessionConfig: config.config, + enabled: spec.enabled !== false, catchUp: !!spec.catchUp, + sourceFile: typeof spec.sourceFile === 'string' ? spec.sourceFile : null, + created: new Date().toISOString(), + }; + db.insertSchedule(row); + if (row.projectId) db.updateProject(row.projectId, { modified: row.created }); + notifyRendererProjectsChanged(); + return { ok: true, schedule: scheduleNode(db.getSchedule(row.id)) }; +} + +function updateSchedule(id, patch) { + const row = db.getSchedule(id); + if (!row) return { error: 'Schedule not found' }; + const clean = {}; + if (patch?.sessionConfig !== undefined) { + const config = normalizeScheduleConfig(patch.sessionConfig); + if (config.error) return config; + clean.sessionConfig = config.config; + } + if (typeof patch?.name === 'string') { + const name = patch.name.trim(); + if (!name) return { error: 'Give the task a name' }; + clean.name = name; + } + if (typeof patch?.prompt === 'string') { + const prompt = patch.prompt.trim(); + if (!prompt) return { error: 'The task needs a prompt' }; + clean.prompt = prompt; + } + // A schedule can move between tracks of its project, but never between a + // project and a folder: that is a different row in a different place. + if (patch?.trackId !== undefined) { + if (!row.projectId) return { error: 'A folder schedule has no track' }; + const trackId = patch.trackId || null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== row.projectId) return { error: 'Track not found in that project' }; + } + clean.trackId = trackId; + } + if (SCHEDULE_TIMING_KEYS.some(k => patch?.[k] !== undefined)) { + const timing = normalizeScheduleTiming(patch, row); + if (timing.error) return { error: timing.error }; + Object.assign(clean, timing.timing); + } + if (patch?.cli !== undefined) { + const cli = normalizeTrackCli(patch.cli); + if (cli.error) return { error: cli.error }; + clean.cli = cli.cli; + } + if (patch?.enabled !== undefined) clean.enabled = !!patch.enabled; + if (patch?.catchUp !== undefined) clean.catchUp = !!patch.catchUp; + if (!Object.keys(clean).length) return { ok: true, schedule: scheduleNode(row) }; + db.updateSchedule(id, clean); + if (row.projectId) db.updateProject(row.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true, schedule: scheduleNode(db.getSchedule(id)) }; +} + +function deleteSchedule(id) { + const row = db.getSchedule(id); + if (!row) return { error: 'Schedule not found' }; + db.deleteSchedule(id); + if (row.projectId) db.updateProject(row.projectId, { modified: new Date().toISOString() }); + notifyRendererProjectsChanged(); + return { ok: true }; +} + +/** + * Why a schedule is not firing right now, in words, or null when it would. + * Pausing is derived: a done project or track pauses its schedules and + * reopening resumes them, with nothing stored. + */ +function schedulePausedReason(row) { + if (!row.enabled) return 'off'; + if (!row.projectId) return null; + const project = db.getProject(row.projectId); + if (!project) return 'project missing'; + if (project.status === 'done') return 'project done'; + if (row.trackId) { + const track = db.getTrack(row.trackId); + if (track && track.status === 'done') return 'track done'; + } + return null; +} + +/** + * Everything the renderer needs to start the session for a schedule, resolved + * now rather than stored: a project schedule runs where its track's sessions + * start, so moving the track moves it. { schedule, target, runtime } or + * { error }. + */ +function resolveScheduleLaunch(id) { + const row = db.getSchedule(id); + if (!row) return { error: 'Schedule not found' }; + const context = resolveScheduleContext(row); + if (context.error) return context; + if (!isDirectory(context.target.projectPath)) return { error: `Not a directory: ${context.target.projectPath}` }; + return { schedule: scheduleNode(row), ...context }; +} + +// Shared by the dialog's preview and the actual launch. Defaults are resolved +// from the current track/project folder, never copied into the schedule row. +function resolveScheduleContext(row) { + let target; + let runtime = row.cli || null; + if (row.projectId) { + const project = db.getProject(row.projectId); + if (!project) return { error: 'Project not found' }; + const track = row.trackId ? db.getTrack(row.trackId) : null; + if (row.trackId && (!track || track.projectId !== project.id)) return { error: 'Track not found in that project' }; + target = { + projectPath: track?.cwd || project.defaultCwd || project.root, + projectId: project.id, + trackId: track ? track.id : null, + projectName: project.name, + }; + if (!runtime && track?.cli) runtime = track.cli; + } else { + if (typeof row.cwd !== 'string' || !path.isAbsolute(row.cwd)) return { error: 'A folder schedule needs a folder' }; + target = { projectPath: row.cwd, projectId: null, trackId: null, projectName: null }; + } + return { target, runtime: runtime || 'claude' }; +} + +/** + * The schedules that should fire in the minute containing `now`. `isBusy` + * says whether a session id is still working (the CLI busy, not merely the + * terminal open): a schedule whose last run has not finished is skipped + * rather than doubled up. + */ +function dueSchedules(now, isBusy = () => false) { + const out = []; + for (const row of db.listSchedules()) { + if (schedulePausedReason(row)) continue; + if (!scheduleTime.dueThisMinute(row, now)) continue; + if (row.lastSessionId && isBusy(row.lastSessionId)) { + log.info?.(`[schedule] Skipping ${row.name} — previous run still working`); + continue; + } + out.push(row.id); + } + return out; +} + +/** Schedules that asked to catch up and were due while the app was closed. */ +function missedSchedules(now, isBusy = () => false) { + const out = []; + for (const row of db.listSchedules()) { + if (schedulePausedReason(row)) continue; + if (!scheduleTime.missedRun(row, now.getTime())) continue; + if (row.lastSessionId && isBusy(row.lastSessionId)) continue; + out.push(row.id); + } + return out; +} + +/** Called from open-terminal when the launch options name a schedule. */ +function recordScheduleRun(scheduleId, sessionId) { + if (!scheduleId || !db.getSchedule(scheduleId)) return false; + db.recordScheduleRun(scheduleId, sessionId, new Date().toISOString()); + notifyRendererProjectsChanged(); + return true; +} + +/** Preserve the old runner's options without inheriting new folder defaults. */ +function legacyScheduleConfig(cli = {}) { + return sessionConfig.normalizeByCli({ claude: { + permissionMode: cli['permission-mode'] === 'default' ? null : cli['permission-mode'] || 'acceptEdits', + allowedTools: cli['allowed-tools'] || 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', + appendSystemPrompt: cli['append-system-prompt'] || '', + addDirs: cli['add-dirs'] || '', + dangerouslySkipPermissions: false, + worktree: false, worktreeName: '', chrome: false, mcpEmulation: false, + preLaunchCmd: '', + // The old runner passed --model. Effort did not exist, so it is pinned + // unset rather than picking up a folder default the old run never had. + model: cli.model || '', + effort: '', + // Budget is deliberately omitted from legacy migration. + } }); +} + +/** + * Import each old file-based schedule once. Each schedule-*.md becomes + * a folder schedule on the folder it sits in. The file stays the source of + * truth: the prompt tells the CLI to read it. Returns how many were imported. + */ +function importLegacySchedules(scanned) { + // The old global completion flag could represent an empty or partial scan. + // Only individual files committed to the import ledger count as finished. + // Ledger entries outlive deleted schedules so retries cannot resurrect them. + const existing = new Set(db.getImportedScheduleFiles()); + let count = 0; + for (const s of scanned || []) { + if (!s.filePath || existing.has(s.filePath)) continue; + if (!isDirectory(s.projectPath)) continue; + let config; + try { + config = legacyScheduleConfig(s.cli); + } catch (err) { + // An invalid file must not prevent other imports or mark itself complete. + log.error?.(`[schedule] Failed to import ${s.filePath}: ${err.message}`); + continue; + } + const preset = scheduleTime.presetFromCron(s.cron); + const timing = preset + ? { every: preset.every, atHour: preset.atHour ?? null, atMinute: preset.atMinute ?? null, weekday: preset.weekday ?? null, cron: null } + : { every: 'cron', atHour: null, atMinute: null, weekday: null, cron: String(s.cron).trim() }; + const imported = db.importLegacySchedule({ + id: crypto.randomUUID(), + name: s.name || path.basename(s.filePath, '.md').replace(/^schedule-/, ''), + projectId: null, trackId: null, cwd: s.projectPath, + prompt: `Run the scheduled task defined in ${s.filePath}. Read that file and follow its instructions.`, + ...timing, + cli: 'claude', sessionConfig: config, enabled: s.enabled !== false, catchUp: false, + sourceFile: s.filePath, + created: new Date().toISOString(), + }); + existing.add(s.filePath); + if (imported) count++; + } + if (count) notifyRendererProjectsChanged(); + return count; +} + +// --- Session assignment --- + +/** Explicitly file a session. projectId null clears both ids. */ +function assignSession(sessionId, projectId, trackId) { + if (!sessionId) return { error: 'Session id is required' }; + if (!projectId) { + db.setSessionAssignment(sessionId, null, null); + notifyRendererProjectsChanged(); + return { ok: true }; + } + if (!db.getProject(projectId)) return { error: 'Project not found' }; + let cleanTrack = null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== projectId) return { error: 'Track not found in that project' }; + cleanTrack = trackId; + } + db.setSessionAssignment(sessionId, projectId, cleanTrack); + notifyRendererProjectsChanged(); + return { ok: true }; +} + +/** + * Called from open-terminal for a brand-new session whose launch options carry + * a projectId. Returns the assignment that was recorded, or null. + */ +function recordLaunchAssignment(sessionId, options) { + const projectId = options?.projectId; + if (!projectId || !db.getProject(projectId)) return null; + let trackId = options?.trackId || null; + if (trackId) { + const track = db.getTrack(trackId); + if (!track || track.projectId !== projectId) trackId = null; + } + db.setSessionAssignment(sessionId, projectId, trackId); + return { projectId, trackId }; +} + +// --- The tree --- + +/** The project whose root contains cwd, or null. Explicit filing beats this. */ +function projectForCwd(cwd, projectRows) { + if (!cwd) return null; + const real = realOrResolved(cwd); + const rows = projectRows || db.listProjects(); + for (const row of rows) { + if (isInside(real, realOrResolved(row.root))) return row; + } + return null; +} + +/** + * Every project with its folders, tracks and sessions. Sessions come from the + * same buildProjectsFromCache() the Sessions tab uses, so a row here is the + * exact object the sidebar already knows how to render. + */ +function buildProjectTree(showArchived) { + const projectRows = db.listProjects(); + if (!projectRows.length) return { projects: [] }; + + const folderRows = db.listAllProjectFolders(); + const trackRows = db.listAllTracks(); + const scheduleRows = db.listSchedules(); + const byId = new Map(); + for (const row of projectRows) { + byId.set(row.id, projectNode( + row, + folderRows.filter(f => f.projectId === row.id), + trackRows.filter(t => t.projectId === row.id), + scheduleRows.filter(t => t.projectId === row.id) + )); + } + const rootsById = new Map(projectRows.map(r => [r.id, realOrResolved(r.root)])); + const cwdCache = new Map(); + const projectIdForCwd = (cwd) => { + if (!cwd) return null; + if (cwdCache.has(cwd)) return cwdCache.get(cwd); + const real = realOrResolved(cwd); + let found = null; + for (const [id, root] of rootsById) { + if (isInside(real, root)) { found = id; break; } + } + cwdCache.set(cwd, found); + return found; + }; + + const folders = buildProjectsFromCache(showArchived) || []; + for (const folder of folders) { + for (const session of folder.sessions || []) { + let pid = session.projectId && byId.has(session.projectId) ? session.projectId : null; + if (!pid) pid = projectIdForCwd(session.projectPath); + if (!pid) continue; + const node = byId.get(pid); + const track = session.trackId ? node.tracks.find(t => t.id === session.trackId) : null; + (track ? track.sessions : node.sessions).push(session); + } + } + + const byModified = (a, b) => new Date(b.modified || 0) - new Date(a.modified || 0); + const projects = []; + for (const node of byId.values()) { + node.sessions.sort(byModified); + let latest = null; + let count = node.sessions.length; + for (const s of node.sessions) if (!latest || s.modified > latest) latest = s.modified; + for (const track of node.tracks) { + track.sessions.sort(byModified); + count += track.sessions.length; + for (const s of track.sessions) if (!latest || s.modified > latest) latest = s.modified; + } + node.lastActivity = latest; + node.sessionCount = count; + projects.push(node); + } + + projects.sort((a, b) => { + if (a.status !== b.status) return a.status === 'active' ? -1 : 1; + const aKey = a.status === 'active' ? (a.lastActivity || a.modified) : a.modified; + const bKey = b.status === 'active' ? (b.lastActivity || b.modified) : b.modified; + return new Date(bKey || 0) - new Date(aKey || 0); + }); + + return { projects }; +} + +// --- Plan tracker and todos --- +// +// plan-tracker.md and todos.md are parsed with public/plan-parser.js, the same +// code the page uses. Ticks are written line by line so the rest of the file +// stays as the agent wrote it. A watcher on each project folder notices ticks +// made by a session and records which session did them (plan_links), which is +// how a phase shows the sessions that worked on it. + +const TRACKER_FILE = 'plan-tracker.md'; +const TODOS_FILE = 'todos.md'; +const PLAN_FILE_BY_KIND = { plan: TRACKER_FILE, todos: TODOS_FILE }; + +function readTextOrEmpty(filePath) { + try { return fs.readFileSync(filePath, 'utf8'); } catch { return ''; } +} + +function hasBody(content) { + return String(content || '').replace(//g, '').split(/\r?\n/) + .some(line => line.trim() && !/^#\s/.test(line.trim())); +} + +/** Everything the Plan tab shows: parsed tracker and todos, which files exist, and the session links. */ +function readProjectPlan(projectId) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const planPath = path.join(project.root, 'plan.md'); + const trackerPath = path.join(project.root, TRACKER_FILE); + const todosPath = path.join(project.root, TODOS_FILE); + const trackerText = readTextOrEmpty(trackerPath); + const todosText = readTextOrEmpty(todosPath); + return { + ok: true, + plan: planParser.parsePlan(trackerText), + todos: planParser.parseTodos(todosText), + links: db.listPlanLinks(projectId), + planPath, trackerPath, todosPath, + hasPlan: hasBody(readTextOrEmpty(planPath)), + hasTracker: fs.existsSync(trackerPath), + hasTodos: fs.existsSync(todosPath), + }; +} + +/** Tick or untick one line of the tracker ('plan') or the todos ('todos'). */ +function setPlanItem(projectId, kind, lineNo, done) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = PLAN_FILE_BY_KIND[kind]; + if (!name) return { error: `Unknown file: ${kind}` }; + const filePath = path.join(project.root, name); + if (!fs.existsSync(filePath)) return { error: `${name} does not exist yet` }; + const result = planParser.toggleLine(fs.readFileSync(filePath, 'utf8'), Number(lineNo), !!done); + if (!result.ok) return { error: 'That line is not a checkbox' }; + fs.writeFileSync(filePath, result.content, 'utf8'); + // The page did this, not a session: the watcher must not link it. + planWatchers.get(projectId)?.snapshots.set(name, result.content); + return { ok: true, text: result.text }; +} + +/** Rewrite the text of one checkbox line of the tracker ('plan') or the todos ('todos'). */ +function editPlanItem(projectId, kind, lineNo, text) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = PLAN_FILE_BY_KIND[kind]; + if (!name) return { error: `Unknown file: ${kind}` }; + const filePath = path.join(project.root, name); + if (!fs.existsSync(filePath)) return { error: `${name} does not exist yet` }; + if (!String(text || '').trim()) return { error: 'Nothing to save' }; + const result = planParser.setLineText(fs.readFileSync(filePath, 'utf8'), Number(lineNo), text); + if (!result.ok) return { error: 'That line is not a checkbox' }; + fs.writeFileSync(filePath, result.content, 'utf8'); + planWatchers.get(projectId)?.snapshots.set(name, result.content); + return { ok: true, text: result.text, previous: result.previous }; +} + +/** Append "- [ ] text" to the todos (or the tracker), creating the file when missing. */ +function appendPlanItem(projectId, kind, text) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const name = PLAN_FILE_BY_KIND[kind]; + if (!name) return { error: `Unknown file: ${kind}` }; + const clean = String(text || '').trim(); + if (!clean) return { error: 'Nothing to add' }; + const filePath = path.join(project.root, name); + const heading = `${project.name} ${kind === 'plan' ? 'plan tracker' : 'todos'}`; + const content = planParser.appendItem(readTextOrEmpty(filePath), clean, heading); + fs.writeFileSync(filePath, content, 'utf8'); + planWatchers.get(projectId)?.snapshots.set(name, content); + return { ok: true }; +} + +/** Remember which session started or finished an item. */ +function recordPlanLink(projectId, kind, itemText, sessionId, linkKind) { + if (!db.getProject(projectId) || !itemText || !sessionId) return false; + db.insertPlanLink({ projectId, file: kind === 'todos' ? 'todos' : 'plan', itemText: String(itemText), sessionId, kind: linkKind, at: new Date().toISOString() }); + return true; +} + +/** + * Copy a plan-mode plan from ~/.claude/plans into the project as plan.md. + * The tracker is left for a session to derive; the page says so. + */ +function adoptPlan(projectId, filename, { replace = false } = {}) { + const project = db.getProject(projectId); + if (!project) return { error: 'Project not found' }; + const source = path.join(plansDir, path.basename(String(filename || ''))); + if (!fs.existsSync(source)) return { error: 'That plan file no longer exists' }; + const target = path.join(project.root, 'plan.md'); + if (!replace && hasBody(readTextOrEmpty(target))) { + return { error: 'plan.md already has content', exists: true }; + } + try { fs.copyFileSync(source, target); } catch (err) { return { error: err.message }; } + notifyRendererProjectsChanged(); + return { ok: true, planPath: target }; +} + +// The watcher: one fs.watch per project folder, looking only at the two +// checklist files. When a tick appears that the page did not make, the most +// recently started running session of that project gets the credit. +const planWatchers = new Map(); // projectId → { watcher, snapshots: Map } +let planCtx = null; // { activeSessions, send } + +function initPlanWatch(ctx) { + planCtx = ctx; + refreshPlanWatchers(); +} + +function refreshPlanWatchers() { + if (!planCtx) return; + const rows = db.listProjects(); + const live = new Set(rows.map(r => r.id)); + for (const [id, entry] of planWatchers) { + if (live.has(id)) continue; + try { entry.watcher.close(); } catch {} + planWatchers.delete(id); + } + for (const row of rows) { + if (planWatchers.has(row.id) || !isDirectory(row.root)) continue; + startPlanWatcher(row); + } +} + +function startPlanWatcher(row) { + const snapshots = new Map(); + for (const name of [TRACKER_FILE, TODOS_FILE]) snapshots.set(name, readTextOrEmpty(path.join(row.root, name))); + const pending = new Set(); + let timer = null; + let watcher; + try { + watcher = fs.watch(row.root, (_event, filename) => { + const name = String(filename || ''); + if (name !== TRACKER_FILE && name !== TODOS_FILE) return; + pending.add(name); + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + const names = [...pending]; + pending.clear(); + for (const n of names) { + try { onPlanFileChanged(row.id, n); } catch (err) { log.error?.('[projects] plan watch failed', err); } + } + }, 300); + }); + } catch (err) { + log.error?.(`[projects] could not watch ${row.root}`, err); + return; + } + watcher.on('error', (err) => log.error?.(`[projects] watcher error for ${row.root}`, err)); + planWatchers.set(row.id, { watcher, snapshots }); +} + +/** The running session of a project that most recently started, or null. */ +function runningProjectSession(projectId) { + let best = null; + for (const [id, session] of planCtx.activeSessions || []) { + if (session.exited || session.isPlainTerminal || session.projectId !== projectId) continue; + const at = session._openedAt || 0; + if (!best || at > best.at) best = { id: session.realSessionId || id, at }; + } + return best ? best.id : null; +} + +function onPlanFileChanged(projectId, name) { + const project = db.getProject(projectId); + const entry = planWatchers.get(projectId); + if (!project || !entry) return; + const next = readTextOrEmpty(path.join(project.root, name)); + const prev = entry.snapshots.get(name) || ''; + entry.snapshots.set(name, next); + if (next === prev) return; + const before = planParser.tickedTexts(prev); + const newly = [...planParser.tickedTexts(next)].filter(text => !before.has(text)); + if (newly.length) { + const sessionId = runningProjectSession(projectId); + if (sessionId) { + for (const text of newly) recordPlanLink(projectId, name === TRACKER_FILE ? 'plan' : 'todos', text, sessionId, 'ticked'); + } + } + planCtx.send?.('project-plan-changed', projectId); +} + +function stopPlanWatchers() { + for (const entry of planWatchers.values()) { try { entry.watcher.close(); } catch {} } + planWatchers.clear(); + // Until initPlanWatch is called again, creating a project starts no watcher + // (a stray fs.watch keeps a process alive). + planCtx = null; +} + +// --- Live git state of attached folders --- +// A worktree's branch is stored when it is attached, but an in-place folder is +// on whatever its owner has checked out right now, so the renderer asks for +// this and shows it beside the folder. Cached per path so a page that +// re-renders often does not shell out to git each time. +const GIT_STATUS_TTL_MS = 30 * 1000; +const gitStatusCache = new Map(); // folder path → { at, status } + +/** { git: false } | { git: true, branch, dirty, ahead, behind } for one folder, cached briefly. */ +async function folderGitInfo(folderPath, { force = false } = {}) { + const cached = gitStatusCache.get(folderPath); + if (!force && cached && Date.now() - cached.at < GIT_STATUS_TTL_MS) return cached.status; + let status = { git: false }; + try { + if (await git.isGitRepo(folderPath)) status = { git: true, ...(await git.status(folderPath)) }; + } catch (err) { + log.info('[projects] git status failed for', folderPath, err.message); + } + gitStatusCache.set(folderPath, { at: Date.now(), status }); + return status; +} + +/** { ok, byPath: { [folderPath]: folderGitInfo(folderPath) } } for every folder of a project. */ +async function folderGitStatus(projectId, { force = false } = {}) { + if (!db.getProject(projectId)) throw new Error('Project not found'); + const byPath = {}; + await Promise.all(db.listProjectFolders(projectId).map(async (folder) => { + byPath[folder.path] = await folderGitInfo(folder.path, { force }); + })); + return { ok: true, byPath }; +} + +/** Detailed, read-only Git information for the repositories attached to a project. */ +async function projectGitInfo(projectId) { + if (!db.getProject(projectId)) throw new Error('Project not found'); + const folders = db.listProjectFolders(projectId); + const repositories = await Promise.all(folders.map(async (folder) => ({ + path: folder.path, + mode: folder.mode || 'in-place', + sourcePath: folder.sourcePath || null, + ...(await git.snapshot(folder.path)), + }))); + return { ok: true, repositories }; +} + +/** Diff one changed file, restricted to a folder already attached to the project. */ +async function projectGitDiff(projectId, folderPath, filePath) { + if (!db.getProject(projectId)) throw new Error('Project not found'); + const folder = db.listProjectFolders(projectId).find(item => item.path === folderPath); + if (!folder) throw new Error('Repository is not attached to this project'); + return { ok: true, ...(await git.fileDiff(folder.path, filePath)) }; +} + +module.exports = { + init, + projectsRoot, slugify, uniqueSlug, defaultBrief, + createProject, updateProject, deleteProject, attachFolder, detachFolder, + folderGitStatus, folderGitInfo, projectGitInfo, projectGitDiff, + syncProjectBrief, syncAllProjectBriefs, saveBrief, createProjectFile, addProjectFiles, listAddedFiles, listRecentProjectFiles, + launchContext, mergeAddDirs, worktreeParentFor, + PROJECT_FILES, ADDED_FILES_DIR, + readProjectPlan, setPlanItem, appendPlanItem, recordPlanLink, adoptPlan, + initPlanWatch, refreshPlanWatchers, stopPlanWatchers, + listTemplates, templatesRoot, renderTemplateText, editPlanItem, + listEnvFiles, defaultEnvSelection, copyEnvFiles, + createTrack, updateTrack, deleteTrack, + listSchedules, createSchedule, updateSchedule, deleteSchedule, resolveScheduleLaunch, resolveScheduleContext, + dueSchedules, missedSchedules, recordScheduleRun, importLegacySchedules, schedulePausedReason, + assignSession, recordLaunchAssignment, + projectForCwd, buildProjectTree, +}; diff --git a/public/app.js b/public/app.js index 913fb962..ec1217fd 100644 --- a/public/app.js +++ b/public/app.js @@ -40,6 +40,7 @@ const terminalArea = document.getElementById('terminal-area'); const settingsViewer = document.getElementById('settings-viewer'); const globalSettingsBtn = document.getElementById('global-settings-btn'); const addProjectBtn = document.getElementById('add-project-btn'); +const projectsContent = document.getElementById('projects-content'); const resortBtn = document.getElementById('resort-btn'); const jsonlViewer = document.getElementById('jsonl-viewer'); const jsonlViewerTitle = document.getElementById('jsonl-viewer-title'); @@ -52,11 +53,19 @@ let gridViewActive = localStorage.getItem('gridViewActive') === '1'; // Map const openSessions = new Map(); window._openSessions = openSessions; -let activeSessionId = sessionStorage.getItem('activeSessionId') || null; +// sessionStorage covers renderer reloads; localStorage also restores the last +// terminal after the Electron process itself restarts. +const ACTIVE_SESSION_KEY = 'activeSessionId'; +let activeSessionId = sessionStorage.getItem(ACTIVE_SESSION_KEY) || localStorage.getItem(ACTIVE_SESSION_KEY) || null; function setActiveSession(id) { activeSessionId = id; - if (id) sessionStorage.setItem('activeSessionId', id); - else sessionStorage.removeItem('activeSessionId'); + if (id) { + sessionStorage.setItem(ACTIVE_SESSION_KEY, id); + localStorage.setItem(ACTIVE_SESSION_KEY, id); + } else { + sessionStorage.removeItem(ACTIVE_SESSION_KEY); + localStorage.removeItem(ACTIVE_SESSION_KEY); + } // Update file panel to show this session's open files/diffs if (typeof switchPanel === 'function') switchPanel(id); } @@ -75,8 +84,22 @@ let showRunningOnly = false; let showTodayOnly = false; let cachedProjects = []; let cachedAllProjects = []; +// Projects tab (projects-view.js): project → tracks → sessions, from +// getProjectTree. Same session objects as the two caches above (dedupTree). +let cachedProjectTree = { projects: [] }; // archived excluded +let cachedProjectTreeAll = { projects: [] }; // everything let activePtyIds = new Set(); let sortedOrder = []; // [{ projectPath, itemIds: [itemId, ...] }, ...] — single source of truth for sidebar order +// Only Sessions and Projects are remembered: the others (Plans, Agent Files, +// Stats) are places you visit, not places you work from. +const REMEMBERED_TABS = ['sessions', 'projects']; +const LAST_TAB_KEY = 'lastTab'; +function rememberedTab() { + try { + const saved = localStorage.getItem(LAST_TAB_KEY); + return REMEMBERED_TABS.includes(saved) ? saved : 'sessions'; + } catch { return 'sessions'; } +} let activeTab = 'sessions'; let cachedPlans = []; let visibleSessionCount = 10; @@ -113,6 +136,53 @@ const attentionSessions = new Set(); // sessions needing user action (OSC 9) const responseReadySessions = new Set(); // CLI finished, user hasn't looked (terminal state) const sessionBusyState = new Map(); // sessionId → boolean (currently active) +// Unread and needs-you outlive the app. A session that finished, or asked for +// something, while you were away still says so after a restart; only opening +// it (or Mark as read) clears it. Busy is not saved: nothing is running yet. +const SESSION_NOTICES_KEY = 'sessionNotices'; + +// When something last happened to a session that is worth moving it for: a +// new session started, a turn finished, or the CLI asked for something. +// Opening or resuming a session does not count, so the list holds still under +// a click. Not persisted: every event coincides with a transcript write, so +// after a restart the transcript's own last-message time says the same thing. +const sessionEventTimes = new Map(); // sessionId → ms since epoch + +function bumpSessionEvent(sessionId) { + if (!sessionId) return; + sessionEventTimes.set(sessionId, Date.now()); + saveSessionNotices(); + if (typeof refreshProjectViews === 'function') refreshProjectViews({ reason: 'sessions' }); +} + +/** + * The time to sort a session by: the later of its last event and the + * transcript's last message. A working session rewrites its transcript + * constantly, so while the CLI is busy the session keeps the time it had when + * the turn began (frozen in setActivity); the turn ending moves it. + */ +function sessionEventTime(session) { + const id = session.sessionId; + const known = sessionEventTimes.get(id) || 0; + const t = new Date(session.modified).getTime(); + const modified = Number.isFinite(t) ? t : 0; + if (known && sessionBusyState.get(id) === true) return known; + return Math.max(known, modified); +} + +function saveSessionNotices() { + try { + // Ids of sessions that no longer exist cost nothing but should not pile up. + const cap = (set) => [...set].slice(-200); + localStorage.setItem(SESSION_NOTICES_KEY, JSON.stringify({ ready: cap(responseReadySessions), attention: cap(attentionSessions) })); + } catch {} +} +try { + const saved = JSON.parse(localStorage.getItem(SESSION_NOTICES_KEY) || 'null'); + for (const id of saved?.ready || []) responseReadySessions.add(id); + for (const id of saved?.attention || []) attentionSessions.add(id); +} catch {} + // Some CLIs (notably Codex) start under a temporary ID and are re-keyed once // their transcript appears. Activity often begins before that detection, so it // must move with the rest of the session or the eventual idle event will have @@ -127,6 +197,14 @@ function rekeySessionActivity(oldId, newId) { sessionBusyState.delete(oldId); } if (activePtyIds.delete(oldId)) activePtyIds.add(newId); + if (sessionEventTimes.has(oldId)) { sessionEventTimes.set(newId, sessionEventTimes.get(oldId)); sessionEventTimes.delete(oldId); } + saveSessionNotices(); +} + +// A session row can be on screen twice: under its folder in the Sessions tab +// and in a project's pane or track card. State classes go to every copy. +function forEachSessionItem(sessionId, fn) { + document.querySelectorAll(`.session-item[data-session-id="${sessionId}"], .pane-session[data-session-id="${sessionId}"]`).forEach(fn); } // Central activity dispatcher @@ -137,8 +215,7 @@ function setActivity(sessionId, active) { // progress start must be able to put the session straight back into running. if (active && responseReadySessions.has(sessionId)) { responseReadySessions.delete(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.remove('response-ready'); + forEachSessionItem(sessionId, item => item.classList.remove('response-ready')); } if (responseReadySessions.has(sessionId)) { @@ -147,32 +224,39 @@ function setActivity(sessionId, active) { const wasActive = sessionBusyState.get(sessionId) || false; sessionBusyState.set(sessionId, active); + if (active && typeof hideSessionHoverPreview === 'function') hideSessionHoverPreview(sessionId); + // A turn is starting: pin the row where it is until the turn ends. + if (active && !wasActive) { + const session = sessionMap.get(sessionId); + if (session) sessionEventTimes.set(sessionId, sessionEventTime(session)); + } if (wasActive && !active) { + bumpSessionEvent(sessionId); // Activity ended → response-ready if user isn't looking at this session if (sessionId !== activeSessionId) { responseReadySessions.add(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) { + forEachSessionItem(sessionId, item => { item.classList.remove('cli-busy'); item.classList.add('response-ready'); - } + }); } } // Sync cli-busy class (only if not response-ready) if (!responseReadySessions.has(sessionId)) { - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.toggle('cli-busy', active); + forEachSessionItem(sessionId, item => item.classList.toggle('cli-busy', active)); } + // The Projects tab rolls working / finished / needs-you up onto its rows. + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } function clearUnread(sessionId) { responseReadySessions.delete(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) { - item.classList.remove('response-ready'); - } + forEachSessionItem(sessionId, item => item.classList.remove('response-ready')); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } // User-initiated: put a session back into the response-ready state, as if @@ -182,18 +266,22 @@ function markUnread(sessionId) { if (responseReadySessions.has(sessionId)) return; responseReadySessions.add(sessionId); sessionBusyState.set(sessionId, false); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) { + forEachSessionItem(sessionId, item => { item.classList.remove('cli-busy'); item.classList.add('response-ready'); - } + }); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } function clearNotifications(sessionId) { + // Opening a session is not an event: the row stays where it is so the + // list does not reshuffle under the pointer. clearUnread(sessionId); attentionSessions.delete(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.remove('needs-attention'); + forEachSessionItem(sessionId, item => item.classList.remove('needs-attention')); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); + saveSessionNotices(); } // Terminal themes, utils (cleanDisplayName, formatDate, escapeHtml, shellEscape) // are defined in terminal-themes.js and utils.js (loaded before app.js). @@ -239,6 +327,8 @@ window.api.onSessionDetected((tempId, realId) => { entry.session.sessionId = realId; if (activeSessionId === tempId) setActiveSession(realId); rekeySessionActivity(tempId, realId); + rekeyTerminalHistory(tempId, realId); + if (typeof rekeyProjectSessionState === 'function') rekeyProjectSessionState(tempId, realId); // Re-key in openSessions openSessions.delete(tempId); @@ -279,6 +369,8 @@ window.api.onSessionForked((oldId, newId) => { entry.session.sessionId = newId; if (activeSessionId === oldId) setActiveSession(newId); rekeySessionActivity(oldId, newId); + rekeyTerminalHistory(oldId, newId); + if (typeof rekeyProjectSessionState === 'function') rekeyProjectSessionState(oldId, newId); openSessions.delete(oldId); openSessions.set(newId, entry); @@ -341,9 +433,10 @@ window.api.onProcessExited((sessionId, exitCode, signal, userStopped) => { return; } - // Everything else — plain terminals (always ephemeral) and Claude sessions - // the user ended themselves — goes away. - if (entry) destroySession(sessionId); + // Everything else — a raw shell that exited and harness sessions the user + // ended themselves — goes away, including its retained terminal history. + // Run cleanup even if the pane was already detached from the renderer. + destroySession(sessionId); if (gridViewActive) { gridViewerCount.textContent = gridCards.size + ' session' + (gridCards.size !== 1 ? 's' : ''); } else if (activeSessionId === sessionId) { @@ -362,6 +455,7 @@ window.api.onProcessExited((sessionId, exitCode, signal, userStopped) => { proj.sessions = proj.sessions.filter(s => s.sessionId !== sessionId); } } + removeSessionFromTrees(sessionId); sessionMap.delete(sessionId); refreshSidebar(); // The pending marker can outlive the .jsonl by a beat (reconciliation only @@ -380,8 +474,10 @@ window.api.onTerminalNotification((sessionId, message, kind) => { // "Approval requested: ". if (kind === 'attention' && sessionId !== activeSessionId) { attentionSessions.add(sessionId); - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - if (item) item.classList.add('needs-attention'); + bumpSessionEvent(sessionId); + // The same session can be on screen in both tabs. + document.querySelectorAll(`.session-item[data-session-id="${sessionId}"]`).forEach(item => item.classList.add('needs-attention')); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); } else if (kind === 'idle') { // A completion notification is authoritative even if a quick turn never // produced a busy frame, or its busy state arrived under a temporary ID. @@ -405,7 +501,9 @@ window.api.onCliBusyState((sessionId, busy) => { // --- Single entry point for all sidebar renders --- // resort=true: re-sort items by priority+time (use for user-initiated actions) // resort=false (default): preserve existing DOM order, new items go to top -function refreshSidebar({ resort = false } = {}) { +// `reason` is passed straight to the Projects tab: 'sessions' means only the +// session list moved, so the project page patches itself instead of rebuilding. +function refreshSidebar({ resort = false, reason = 'project' } = {}) { // When searching, always use all projects (search ignores archive filter) let projects = (searchMatchIds !== null) ? cachedAllProjects @@ -425,6 +523,7 @@ function refreshSidebar({ resort = false } = {}) { } renderProjects(projects, resort); + if (typeof refreshProjectViews === 'function') refreshProjectViews({ reason }); } // --- Archive toggle --- @@ -469,7 +568,38 @@ globalSettingsBtn.addEventListener('click', () => { openSettingsViewer('global'); }); -// --- Add project button --- +// --- "More" button: Plans, Agent Files, Stats and Global settings share one +// menu so the tab strip stays short. The tab buttons stay in the DOM, hidden, +// so everything that clicks them (shortcuts, the quota gauge) keeps working. +const sidebarMoreBtn = document.getElementById('sidebar-more-btn'); +const MORE_TABS = ['plans', 'memory', 'stats']; +const moreIdleIcon = sidebarMoreBtn.innerHTML; +const tabButton = (name) => document.querySelector(`.sidebar-tab[data-tab="${name}"]`); +const menuIcon = (svg) => svg.replace(/width="18" height="18"/, 'width="14" height="14"'); + +/** Show the active hidden tab's icon on the more button, or the dots when none is active. */ +function updateMoreButton() { + const tab = MORE_TABS.includes(activeTab) ? tabButton(activeTab) : null; + sidebarMoreBtn.innerHTML = tab ? tab.innerHTML : moreIdleIcon; + sidebarMoreBtn.title = tab ? tab.title : 'More'; + sidebarMoreBtn.classList.toggle('active', !!tab); +} + +sidebarMoreBtn.addEventListener('click', (e) => { + const slackLink = document.getElementById('status-bar-slack'); + const tabItem = (name) => { + const tab = tabButton(name); + return { label: tab.title, icon: menuIcon(tab.innerHTML), muted: activeTab === name, onClick: () => tab.click() }; + }; + showContextMenu([ + ...MORE_TABS.map(tabItem), + { sep: true }, + { label: 'Global settings', icon: ICONS.gear(14), onClick: () => globalSettingsBtn.click() }, + { label: 'Join Slack', icon: slackLink.querySelector('svg').outerHTML, onClick: () => window.api.openExternal(slackLink.href) }, + ], { anchor: e.currentTarget }); +}); + +// --- Add folder / new project buttons --- addProjectBtn.addEventListener('click', () => { showAddProjectDialog(); }); @@ -504,7 +634,7 @@ function clearSearch() { searchInput.value = ''; searchBar.classList.remove('has-query'); if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); searchDebounceTimer = null; } - if (activeTab === 'sessions') { + if (activeTab === 'sessions' || activeTab === 'projects') { searchMatchIds = null; searchMatchProjectPaths = null; refreshSidebar({ resort: true }); @@ -535,7 +665,7 @@ searchInput.addEventListener('input', () => { } try { - if (activeTab === 'sessions') { + if (activeTab === 'sessions' || activeTab === 'projects') { const results = await window.api.search('session', query, searchTitlesOnly); searchMatchIds = new Set(results.map(r => r.id)); // When title-only, also match project names @@ -561,7 +691,7 @@ searchInput.addEventListener('input', () => { renderMemories(matchIds); } } catch { - if (activeTab === 'sessions') { + if (activeTab === 'sessions' || activeTab === 'projects') { searchMatchIds = null; searchMatchProjectPaths = null; refreshSidebar({ resort: true }); @@ -584,6 +714,7 @@ function isDismissibleSession(sessionId) { /** Drop such a row. Purely renderer state, so it cannot come back. */ function dismissSession(sessionId) { + const session = sessionMap.get(sessionId); pendingSessions.delete(sessionId); sessionMap.delete(sessionId); for (const projList of [cachedProjects, cachedAllProjects]) { @@ -591,7 +722,12 @@ function dismissSession(sessionId) { proj.sessions = proj.sessions.filter(s => s.sessionId !== sessionId); } } + if (typeof removeSessionFromTrees === 'function') removeSessionFromTrees(sessionId); if (openSessions.has(sessionId)) destroySession(sessionId); + else { + forgetTerminalHistory(sessionId); + if (session?.type === 'terminal') forgetPersistedTerminalSession(sessionId); + } if (activeSessionId === sessionId) { setActiveSession(null); terminalHeader.style.display = 'none'; @@ -641,6 +777,13 @@ function scheduleActiveSessionsPoll() { async function pollActiveSessions() { try { const ids = await window.api.getActiveSessions(); + // A new session that just came alive is news. A resumed one keeps its + // place until the agent does something. + for (const id of ids) { + if (activePtyIds.has(id)) continue; + const pending = pendingSessions.get(id); + if (pending && !pending.restored) sessionEventTimes.set(id, Date.now()); + } activePtyIds = new Set(ids); updateRunningIndicators(); updateTerminalHeader(); @@ -654,9 +797,10 @@ function updateRunningIndicators() { const running = activePtyIds.has(id); item.classList.toggle('has-running-pty', running); if (!running) { - item.classList.remove('needs-attention', 'response-ready', 'cli-busy'); - attentionSessions.delete(id); - responseReadySessions.delete(id); + // Unread and needs-you stay until the user looks; only busy needs a PTY. + item.classList.remove('cli-busy'); + item.classList.toggle('needs-attention', attentionSessions.has(id)); + item.classList.toggle('response-ready', responseReadySessions.has(id)); sessionBusyState.delete(id); } const dot = item.querySelector('.session-status-dot'); @@ -668,6 +812,7 @@ function updateRunningIndicators() { const dot = group.querySelector('.slug-group-dot'); if (dot) dot.classList.toggle('running', hasRunning); }); + if (typeof updateProjectStatusDots === 'function') updateProjectStatusDots(); // Update grid card dots and status text for (const [sid, card] of gridCards) { const running = activePtyIds.has(sid); @@ -732,23 +877,74 @@ function dedup(projects) { } } -async function loadProjects({ resort = false } = {}) { +/** + * Raw terminals have no transcript/database row. Recreate their renderer rows + * from localStorage before the sidebar/project panes render after a restart. + */ +function injectPersistedTerminalRows() { + for (const saved of persistedTerminalSessions()) { + if (pendingSessions.has(saved.sessionId)) continue; + const session = sessionMap.get(saved.sessionId) || saved; + Object.assign(session, saved); + sessionMap.set(session.sessionId, session); + const folder = encodeProjectPath(session.projectPath); + pendingSessions.set(session.sessionId, { + session, + projectPath: session.projectPath, + folder, + restored: true, + }); + for (const projList of [cachedProjects, cachedAllProjects]) { + let proj = projList.find(p => p.projectPath === session.projectPath); + if (!proj) { + proj = { folder, projectPath: session.projectPath, sessions: [] }; + projList.unshift(proj); + } + if (!proj.sessions.some(item => item.sessionId === session.sessionId)) proj.sessions.unshift(session); + } + injectPendingIntoTree(session); + } +} + +/** Reopen every saved raw terminal as a fresh shell, initially hidden. */ +async function restorePersistedTerminalProcesses() { + const jobs = []; + for (const saved of persistedTerminalSessions()) { + if (openSessions.has(saved.sessionId)) continue; + const session = sessionMap.get(saved.sessionId) || saved; + jobs.push(openRawTerminalSession(session, { show: false })); + } + if (jobs.length) { + await Promise.all(jobs); + await pollActiveSessions(); + } +} + +async function loadProjects({ resort = false, reason = 'project' } = {}) { const wasEmpty = cachedProjects.length === 0; if (wasEmpty) { loadingStatus.textContent = 'Loading\u2026'; loadingStatus.className = 'active'; loadingStatus.style.display = ''; } - const [defaultProjects, allProjects] = await Promise.all([ + const [defaultProjects, allProjects, tree, treeAll] = await Promise.all([ window.api.getProjects(false), window.api.getProjects(true), + window.api.getProjectTree(false).catch(() => ({ projects: [] })), + window.api.getProjectTree(true).catch(() => ({ projects: [] })), + // Scheduled tasks ride along: the folder clocks and session chips read them. + typeof loadSchedules === 'function' ? loadSchedules() : null, ]); cachedProjects = defaultProjects; cachedAllProjects = allProjects; + cachedProjectTree = tree || { projects: [] }; + cachedProjectTreeAll = treeAll || { projects: [] }; loadingStatus.style.display = 'none'; loadingStatus.className = ''; dedup(cachedProjects); dedup(cachedAllProjects); + dedupTree(cachedProjectTree); + dedupTree(cachedProjectTreeAll); // Reconcile pending sessions: remove ones that now have real data let hasReinjected = false; @@ -770,13 +966,14 @@ async function loadProjects({ resort = false } = {}) { proj.sessions.unshift(pending.session); } } + injectPendingIntoTree(pending.session); } } // Track active plain terminals in pendingSessions/sessionMap (data now comes from backend) try { const activeTerminals = await window.api.getActiveTerminals(); - for (const { sessionId, projectPath } of activeTerminals) { + for (const { sessionId, projectPath, projectId, trackId } of activeTerminals) { if (pendingSessions.has(sessionId)) continue; // already tracked const folder = encodeProjectPath(projectPath); // Find the session object already injected by the backend @@ -786,14 +983,28 @@ async function loadProjects({ resort = false } = {}) { if (session) break; } if (!session) continue; + // An attached folder can sit outside the project's root, so cwd alone is + // not enough to restore where this ephemeral terminal belongs. + if (projectId) session.projectId = projectId; + if (trackId) session.trackId = trackId; + // Also adopts terminals that were already running when this persistence + // feature was introduced; the next restart should retain them too. + persistTerminalSession(session); pendingSessions.set(sessionId, { session, projectPath, folder }); sessionMap.set(sessionId, session); } } catch {} - await hydrateProjectTasks([cachedProjects, cachedAllProjects]); + // A full app exit kills raw PTYs, so the main-process active list is empty on + // the next launch. Their durable descriptors still put them back in the same + // project/track and starting folder. + injectPersistedTerminalRows(); + + // Project roots and attached folders get their tasks too, even with no + // sessions of their own, so a project's task menu is complete. + await hydrateProjectTasks([cachedProjects, cachedAllProjects], treeTaskPaths(cachedProjectTreeAll)); await pollActiveSessions(); - refreshSidebar({ resort }); + refreshSidebar({ resort, reason }); renderDefaultStatus(); } @@ -801,7 +1012,7 @@ async function loadProjects({ resort = false } = {}) { // rebindSidebarEvents, buildSessionItem, startRename) → sidebar.js -async function launchNewSession(project, sessionOptions) { +async function launchNewSession(project, sessionOptions, { focus = true } = {}) { // A temporary id. Claude is told to use it (--session-id); codex cannot be, // so main watches for its transcript and sends session-detected with the real // one, which re-keys everything below. @@ -822,6 +1033,22 @@ async function launchNewSession(project, sessionOptions) { created: new Date().toISOString(), }; + // Launched from a project (or one of its tracks): main files the session + // there when it spawns, and the Projects tab shows it right away. + const options = { ...(sessionOptions || {}) }; + if (project.projectId) { + options.projectId = project.projectId; + if (project.trackId) options.trackId = project.trackId; + session.projectId = project.projectId; + session.trackId = project.trackId || null; + } + // Started by a scheduled task: the row shows the chip from the first + // moment, not only once the DB has the link (main records it on spawn). + if (options.scheduleId) { + session.scheduleId = options.scheduleId; + session.scheduledAt = new Date().toISOString(); + } + // Track as pending (no .jsonl yet) const folder = encodeProjectPath(projectPath); pendingSessions.set(sessionId, { session, projectPath, folder }); @@ -836,12 +1063,13 @@ async function launchNewSession(project, sessionOptions) { } proj.sessions.unshift(session); } + injectPendingIntoTree(session); refreshSidebar(); const entry = createTerminalEntry(session); // Open terminal in main process with session options - const result = await window.api.openTerminal(sessionId, projectPath, true, sessionOptions || null); + const result = await window.api.openTerminal(sessionId, projectPath, true, Object.keys(options).length ? options : null); if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; @@ -849,7 +1077,9 @@ async function launchNewSession(project, sessionOptions) { } if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); - showSession(sessionId); + // A scheduled launch runs in the background: it shows up in the lists like + // any session, but does not take over whatever the user is looking at. + if (focus) showSession(sessionId); pollActiveSessions(); } @@ -884,16 +1114,38 @@ async function showTerminalHeader(session) { // Terminal lifecycle (createTerminalEntry, destroySession, showSession, setupDragAndDrop) → terminal-manager.js +async function unarchiveSessionBeforeOpen(session) { + if (!session.archived) return true; + + const displayName = cleanDisplayName(session.name || session.aiTitle || session.summary) || 'This session'; + if (!confirm(`“${displayName}” is archived.\n\nUnarchive it and open it?`)) return false; + + const result = await window.api.archiveSession(session.sessionId, 0); + if (result?.error) { + alert(result.error); + return false; + } + session.archived = 0; + await loadProjects(); + return true; +} + async function openSession(session, customOptions) { + if (!await unarchiveSessionBeforeOpen(session)) return; + const { sessionId, projectPath } = session; // If already open, handle closed-session cleanup or just show it if (openSessions.has(sessionId)) { const entry = openSessions.get(sessionId); if (entry.closed) { - destroySession(sessionId); + destroySession(sessionId, { + forgetPersisted: session.type !== 'terminal', + preserveHistory: true, + }); if (session.type === 'terminal') { - launchTerminalSession({ projectPath: session.projectPath }); + await openRawTerminalSession(session); + pollActiveSessions(); return; } } else { @@ -902,6 +1154,12 @@ async function openSession(session, customOptions) { } } + if (session.type === 'terminal') { + await openRawTerminalSession(session); + pollActiveSessions(); + return; + } + // Create new terminal entry (hidden until showSession) const entry = createTerminalEntry(session); @@ -944,9 +1202,14 @@ window.addEventListener('resize', () => { document.querySelectorAll('.sidebar-tab').forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.tab; - if (tabName === activeTab) return; + // The more button shares the tab styling but opens a menu instead. + if (!tabName || tabName === activeTab) return; + // Leaving the Projects tab takes its page, strip and pane with it. + if (activeTab === 'projects' && typeof leaveProjectViews === 'function') leaveProjectViews(); activeTab = tabName; + if (REMEMBERED_TABS.includes(tabName)) { try { localStorage.setItem(LAST_TAB_KEY, tabName); } catch {} } document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabName)); + updateMoreButton(); // Clear search on tab switch searchInput.value = ''; @@ -956,18 +1219,16 @@ document.querySelectorAll('.sidebar-tab').forEach(tab => { // Hide all sidebar content areas sidebarContent.style.display = 'none'; + projectsContent.style.display = 'none'; plansContent.style.display = 'none'; statsContent.style.display = 'none'; memoryContent.style.display = 'none'; sessionFilters.style.display = 'none'; searchBar.style.display = 'none'; - if (tabName === 'sessions') { - sessionFilters.style.display = ''; - searchBar.style.display = ''; - searchInput.placeholder = 'Search sessions...'; - sidebarContent.style.display = ''; - // Restore terminal area + // Sessions and Projects share the main area: the grid, the active + // terminal, or the placeholder. + function restoreTerminalArea() { hideAllViewers(); if (gridViewActive) { // Grid is still set up — just re-show it and refit @@ -982,11 +1243,30 @@ document.querySelectorAll('.sidebar-tab').forEach(tab => { } else { placeholder.style.display = ''; } + } + + if (tabName === 'sessions') { + sessionFilters.style.display = ''; + searchBar.style.display = ''; + searchInput.placeholder = 'Search sessions...'; + sidebarContent.style.display = ''; + restoreTerminalArea(); // Catch up on changes that happened while on another tab if (projectsChangedWhileAway) { projectsChangedWhileAway = false; loadProjects(); } + } else if (tabName === 'projects') { + searchBar.style.display = ''; + searchInput.placeholder = 'Search projects...'; + projectsContent.style.display = ''; + if (projectsChangedWhileAway) { + projectsChangedWhileAway = false; + loadProjects().then(() => showProjectHome()); + } else { + renderProjectList(); + showProjectHome(); + } } else if (tabName === 'plans') { searchBar.style.display = ''; searchInput.placeholder = 'Search plans...'; @@ -1147,31 +1427,49 @@ setTimeout(() => { })(); loadProjects().then(async () => { + // Open the tab the user was last working in. + const lastTab = rememberedTab(); + if (lastTab !== activeTab) document.querySelector(`.sidebar-tab[data-tab="${lastTab}"]`)?.click(); await restoreActiveTaskView(); + await restorePersistedTerminalProcesses(); // Restore grid view preference before opening sessions so they enter grid mode if (!activeTaskView && localStorage.getItem('gridViewActive') === '1') { showGridView(); } - // Restore active session after reload - if (activeSessionId && !openSessions.has(activeSessionId)) { + // Restore the active session after a renderer reload or full app restart. + // Raw terminals were reopened above but deliberately left hidden until this + // point, so an already-open entry still needs showSession(). + if (activeSessionId) { const session = sessionMap.get(activeSessionId); - if (session) openSession(session); + if (session?.archived) { + setActiveSession(null); + } else if (session) { + if (openSessions.has(activeSessionId)) showSession(activeSessionId); + else openSession(session); + } + else setActiveSession(null); } }); // Live-reload sidebar when filesystem changes are detected let projectsChangedTimer = null; +// The strongest reason seen while the debounce window is open. +let projectsChangedReason = 'sessions'; let projectsChangedWhileAway = false; -window.api.onProjectsChanged(() => { +window.api.onProjectsChanged((reason) => { // Debounce to avoid rapid re-renders during bulk changes if (projectsChangedTimer) clearTimeout(projectsChangedTimer); - if (activeTab !== 'sessions') { + if (activeTab !== 'sessions' && activeTab !== 'projects') { projectsChangedWhileAway = true; return; } + // A batch that mixes both is a project change: the wider refresh covers both. + if (reason !== 'sessions') projectsChangedReason = 'project'; projectsChangedTimer = setTimeout(() => { projectsChangedTimer = null; - loadProjects(); + const only = projectsChangedReason; + projectsChangedReason = 'sessions'; + loadProjects({ reason: only }); }, 300); }); @@ -1185,7 +1483,9 @@ function renderDefaultStatus() { const parts = []; if (running > 0) parts.push(`${running} running`); parts.push(`${totalSessions} sessions`); - parts.push(`${totalProjects} projects`); + parts.push(`${totalProjects} folders`); + const projectCount = (cachedProjectTreeAll?.projects || []).filter(p => p.status === 'active').length; + if (projectCount > 0) parts.push(`${projectCount} project${projectCount === 1 ? '' : 's'}`); statusBarInfo.textContent = parts.join(' \u00b7 '); } diff --git a/public/dialogs.js b/public/dialogs.js index 1e393297..7f4059ed 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -1,31 +1,20 @@ // --- Dialogs & session launch helpers --- // Depends on globals: launchNewSession, cachedProjects, cachedAllProjects, sessionMap, // pendingSessions, openSessions, activePtyIds, refreshSidebar, pollActiveSessions (app.js) -// Depends on: ICONS (icons.js) +// Depends on: ICONS (icons.js)// +// Dialog rule: a click on the backdrop does nothing. These hold typed work, and +// a stray click used to throw it away. Escape and Cancel are the ways out. // --- New session dialog --- async function resolveDefaultSessionOptions(project) { const effective = await window.api.getEffectiveSettings(project.projectPath); - const options = {}; - if (effective.dangerouslySkipPermissions) { - options.dangerouslySkipPermissions = true; - } else if (effective.permissionMode) { - options.permissionMode = effective.permissionMode; - } - if (effective.worktree) { - options.worktree = true; - if (effective.worktreeName) options.worktreeName = effective.worktreeName; - } - if (effective.chrome) options.chrome = true; - if (effective.preLaunchCmd) options.preLaunchCmd = effective.preLaunchCmd; - if (effective.addDirs) options.addDirs = effective.addDirs; - if (effective.mcpEmulation === false) options.mcpEmulation = false; - // Passed through for every session; each harness reads only the keys it - // understands and ignores the rest (see buildLaunchArgs). - if (effective.codexSandbox) options.codexSandbox = effective.codexSandbox; - if (effective.codexApproval) options.codexApproval = effective.codexApproval; - if (effective.codexModel) options.codexModel = effective.codexModel; - return options; + // All quick-launch callers share the same option registry as the forms. + // A harness reads only its own fields; explicit false/null/empty defaults + // remain intact instead of being mistaken for missing settings. + const keys = new Set(Object.keys(SessionConfig.FIELDS).flatMap(runtime => + SessionConfig.fieldsFor(runtime).map(field => field.key))); + return Object.fromEntries([...keys].filter(key => SessionConfig.own(effective, key)) + .map(key => [key, effective[key]])); } async function forkSession(session, project) { @@ -37,54 +26,6 @@ async function forkSession(session, project) { launchNewSession(project, options); } -async function launchScheduleCreator(project) { - const options = await resolveDefaultSessionOptions(project); - // Pre-create a JSONL session with the schedule creation prompt, then resume into it - const result = await window.api.createScheduleSession(project.projectPath); - if (!result || !result.sessionId) return; - - const session = { - sessionId: result.sessionId, - summary: 'Create scheduled task', - firstPrompt: '', - projectPath: project.projectPath, - name: null, - starred: 0, - archived: 0, - messageCount: 1, - modified: new Date().toISOString(), - created: new Date().toISOString(), - }; - - // Inject into sidebar - const folder = encodeProjectPath(project.projectPath); - pendingSessions.set(result.sessionId, { session, projectPath: project.projectPath, folder }); - sessionMap.set(result.sessionId, session); - for (const projList of [cachedProjects, cachedAllProjects]) { - let proj = projList.find(p => p.projectPath === project.projectPath); - if (!proj) { - proj = { folder, projectPath: project.projectPath, sessions: [] }; - projList.unshift(proj); - } - proj.sessions.unshift(session); - } - refreshSidebar(); - - const entry = createTerminalEntry(session); - // Resume the pre-seeded session - options.appendSystemPrompt = result.systemPrompt; - options.resumeExisting = true; - const openResult = await window.api.openTerminal(result.sessionId, project.projectPath, false, options); - if (!openResult.ok) { - entry.terminal.write(`\r\nError: ${openResult.error}\r\n`); - entry.closed = true; - return; - } - if (typeof setSessionMcpActive === 'function') setSessionMcpActive(result.sessionId, !!openResult.mcpActive); - showSession(result.sessionId); - pollActiveSessions(); -} - function showNewSessionPopover(project, anchorEl) { // Remove any existing popover document.querySelectorAll('.new-session-popover').forEach(el => el.remove()); @@ -164,14 +105,32 @@ function showNewSessionPopover(project, anchorEl) { setTimeout(() => document.addEventListener('mousedown', onClickOutside), 0); } +/** Start or reattach a raw terminal without routing it through a CLI harness. */ +async function openRawTerminalSession(session, { show = true } = {}) { + const { sessionId, projectPath } = session; + const entry = openSessions.get(sessionId) || createTerminalEntry(session); + const result = await window.api.openTerminal(sessionId, projectPath, true, { + type: 'terminal', + projectId: session.projectId || null, + trackId: session.trackId || null, + }); + if (!result.ok) { + entry.terminal.write(`\r\nError: ${result.error}\r\n`); + entry.closed = true; + return result; + } + entry.closed = false; + persistTerminalSession(session); + if (show) showSession(sessionId); + return result; +} + async function launchTerminalSession(project) { - const sessionId = crypto.randomUUID(); - const projectPath = project.projectPath; const session = { - sessionId, + sessionId: crypto.randomUUID(), summary: 'Terminal', firstPrompt: '', - projectPath, + projectPath: project.projectPath, name: null, starred: 0, archived: 0, @@ -180,8 +139,15 @@ async function launchTerminalSession(project) { created: new Date().toISOString(), type: 'terminal', }; + // Started from a project or one of its tracks: the terminal belongs there, + // so it shows in the project's list and not only in its own window. + if (project.projectId) { + session.projectId = project.projectId; + session.trackId = project.trackId || null; + } // Track as pending + const { sessionId, projectPath } = session; const folder = encodeProjectPath(projectPath); pendingSessions.set(sessionId, { session, projectPath, folder }); @@ -195,192 +161,48 @@ async function launchTerminalSession(project) { } proj.sessions.unshift(session); } + if (typeof injectPendingIntoTree === 'function') injectPendingIntoTree(session); refreshSidebar(); - const entry = createTerminalEntry(session); - - const result = await window.api.openTerminal(sessionId, projectPath, true, { type: 'terminal' }); - if (!result.ok) { - entry.terminal.write(`\r\nError: ${result.error}\r\n`); - entry.closed = true; - return; - } - - showSession(sessionId); + await openRawTerminalSession(session); pollActiveSessions(); } async function showNewSessionDialog(project, runtime = 'claude') { const effective = await window.api.getEffectiveSettings(project.projectPath); - const isCodex = runtime === 'codex'; - const overlay = document.createElement('div'); overlay.className = 'new-session-overlay'; - const dialog = document.createElement('div'); dialog.className = 'new-session-dialog'; - - let selectedMode = effective.permissionMode || null; - let dangerousSkip = effective.dangerouslySkipPermissions || false; - - const modes = PERMISSION_MODES; - - function renderModeGrid() { - return modes.map(m => { - const isSelected = !dangerousSkip && selectedMode === m.value; - return ``; - }).join('') + - ``; - } - - // Codex has no permission modes, no worktree flag and no Chrome integration; - // it has a sandbox policy and an approval policy instead. Showing Claude's - // controls would offer settings that are silently dropped at launch. - const claudeFields = ` -
-
Permission Mode
-
${renderModeGrid()}
-
-
-
- Worktree -
Run session in an isolated git worktree
-
-
- - -
-
-
-
- Chrome -
Enable Chrome browser automation
-
-
- -
-
`; - - const codexFields = ` -
-
- Sandbox -
What Codex is allowed to touch
-
-
- -
-
-
-
- Approval -
When Codex stops to ask before running a command
-
-
- -
-
-
-
- Model -
Blank uses Codex's default
-
-
- -
-
`; - + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); dialog.innerHTML = ` -

New ${isCodex ? 'Codex' : 'Claude'} Session — ${escapeHtml(shortProjectPath(project.projectPath))}

- ${isCodex ? codexFields : claudeFields} -
-
- Pre-launch Command -
Prepended to the ${isCodex ? 'codex' : 'claude'} command
-
-
- -
-
-
-
- Additional Directories -
Extra directories to include (comma-separated)
-
-
- -
-
+

New ${escapeHtml(runtime === 'codex' ? 'Codex' : runtime === 'claude' ? 'Claude' : runtime)} Session — ${escapeHtml(shortProjectPath(project.projectPath))}

+
+
- - -
- `; - + + + `; + const form = SessionConfigForm.mount(dialog.querySelector('.session-config-fields'), { runtime, defaults: effective }); overlay.appendChild(dialog); document.body.appendChild(overlay); - - // Bind mode grid clicks (Claude only — codex has no permission modes) - const modeGrid = dialog.querySelector('#nsd-mode-grid'); - if (modeGrid) modeGrid.addEventListener('click', (e) => { - const btn = e.target.closest('.permission-option'); - if (!btn) return; - const mode = btn.dataset.mode; - if (mode === 'dangerous-skip') { - dangerousSkip = !dangerousSkip; - if (dangerousSkip) selectedMode = null; - } else { - dangerousSkip = false; - selectedMode = mode === 'null' ? null : mode; - } - modeGrid.innerHTML = renderModeGrid(); - }); - - function close() { - overlay.remove(); - document.removeEventListener('keydown', onKey); - } - + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } function start() { - const options = { runtime }; - if (isCodex) { - options.codexSandbox = dialog.querySelector('#nsd-codex-sandbox').value; - options.codexApproval = dialog.querySelector('#nsd-codex-approval').value; - options.codexModel = dialog.querySelector('#nsd-codex-model').value.trim(); - } else { - if (dangerousSkip) { - options.dangerouslySkipPermissions = true; - } else if (selectedMode) { - options.permissionMode = selectedMode; - } - if (dialog.querySelector('#nsd-worktree').checked) { - options.worktree = true; - options.worktreeName = dialog.querySelector('#nsd-worktree-name').value.trim(); - } - if (dialog.querySelector('#nsd-chrome').checked) { - options.chrome = true; - } + let options; + try { options = form.getOptions(); } catch (err) { + const error = dialog.querySelector('.session-config-error'); + error.textContent = err.message; error.hidden = false; + return; } - const preLaunch = dialog.querySelector('#nsd-pre-launch').value.trim(); - if (preLaunch) options.preLaunchCmd = preLaunch; - options.addDirs = dialog.querySelector('#nsd-add-dirs').value.trim(); - if (effective.mcpEmulation === false) options.mcpEmulation = false; close(); - launchNewSession(project, options); + launchNewSession(project, { ...options, runtime }); } - dialog.querySelector('.new-session-cancel-btn').onclick = close; dialog.querySelector('.new-session-start-btn').onclick = start; - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - - // Keyboard support function onKey(e) { if (e.key === 'Escape') close(); - if (e.key === 'Enter' && !e.target.matches('input')) start(); + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) start(); } document.addEventListener('keydown', onKey); } @@ -492,7 +314,6 @@ async function showResumeSessionDialog(session) { dialog.querySelector('.new-session-cancel-btn').onclick = close; dialog.querySelector('.new-session-start-btn').onclick = resume; - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); function onKey(e) { if (e.key === 'Escape') close(); @@ -512,10 +333,10 @@ function showAddProjectDialog() { dialog.className = 'add-project-dialog'; dialog.innerHTML = ` -

Add Project

-
Select a folder to create a new project. To start a session in an existing project, use the + on its project header.
+

Add Folder

+
Select a folder. Its sessions appear in the Sessions tab. To start a session in a folder that is already listed, use the + on its header.
- +
@@ -563,7 +384,6 @@ function showAddProjectDialog() { dialog.querySelector('.add-project-cancel-btn').onclick = close; dialog.querySelector('.add-project-add-btn').onclick = addProject; - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); function onKey(e) { if (e.key === 'Escape') close(); diff --git a/public/file-actions.js b/public/file-actions.js new file mode 100644 index 00000000..b356daea --- /dev/null +++ b/public/file-actions.js @@ -0,0 +1,99 @@ +// Shared context menu for the project Files tab and the session file browser. +// Uses the app's context menu and prompt components from projects-view.js. +function projectEntryPath(root, relativePath = '') { + const separator = window.api.platform === 'win32' ? '\\' : '/'; + return root.replace(/[\\/]$/, '') + (relativePath ? separator + relativePath : ''); +} + +function remapFileActionPath(value, change) { + if (!value) return value; + const normalize = path => { + const normalized = path.replace(/\\/g, '/'); + return window.api.platform === 'win32' ? normalized.toLowerCase() : normalized; + }; + const sources = [ + [projectEntryPath(change.projectPath, change.relativePath), change.newRelativePath && projectEntryPath(change.projectPath, change.newRelativePath)], + [change.filePath, change.newFilePath], + ]; + for (const [from, to] of sources) { + const key = normalize(from); + if (normalize(value) === key || normalize(value).startsWith(key + '/')) { + return to ? to + value.slice(from.length) : null; + } + } + return value; +} + +function remapFileActionRelative(root, relativePath, change) { + if (!relativePath) return relativePath; + const original = projectEntryPath(root, relativePath); + const mapped = remapFileActionPath(original, change); + return mapped === original ? relativePath : mapped === null ? null : mapped.slice(projectEntryPath(root).length + 1); +} + +// A preview read stays side-effect free; only user-initiated open flows call +// this fallback after their stale-request checks. Preserve the current editor. +async function openUnsupportedFile(result, filePath, projectRoot) { + if (result?.code !== 'PREVIEW_UNAVAILABLE') return false; + try { + const opened = await window.api.openFileExternally(filePath, projectRoot); + if (!opened?.ok) throw new Error(opened?.error || 'Could not open the file in its default application.'); + } catch (error) { + alert(error.message || 'Could not open the file in its default application.'); + } + return true; +} + +function fileEntryMenuItems(root, entry, open) { + const manager = window.api.platform === 'darwin' ? 'Finder' : window.api.platform === 'win32' ? 'Explorer' : 'File Manager'; + const trash = window.api.platform === 'win32' ? 'Recycle Bin' : 'Trash'; + const run = action => async () => { + try { + let name; + if (action === 'rename' || action === 'trash') { + if (typeof canManageBrowserEntry === 'function' && !canManageBrowserEntry(root, entry.relativePath)) { + alert('Resolve or close the open diff for this item before changing it.'); + return; + } + } + if (action === 'rename') { + name = await showPromptDialog({ title: 'Rename ' + entry.name, label: 'Name', value: entry.name, confirm: 'Rename' }); + if (!name || name === entry.name) return; + } + const result = await window.api.manageProjectEntry(root, entry.relativePath, action, name); + if (!result?.ok) throw new Error(result?.error || 'Could not complete the file action.'); + if (result.cancelled || (action !== 'rename' && action !== 'trash')) return; + if (typeof applyProjectFileAction === 'function') await applyProjectFileAction(result); + if (typeof applyBrowserFileAction === 'function') await applyBrowserFileAction(result); + } catch (err) { + alert(err.message || 'Could not complete the file action.'); + } + }; + return [ + { head: entry.name }, + entry.type === 'directory' + ? { label: 'Open folder', icon: PICONS.folder(14), onClick: run('open-folder') } + : { label: entry.viewable ? 'Open in editor / preview' : 'Open in default application', icon: PICONS.file(14), disabled: entry.type !== 'file', onClick: open }, + { label: 'Reveal in ' + manager, icon: PICONS.open(14), onClick: run('reveal') }, + { sep: true }, + { label: 'Copy path', onClick: () => window.api.writeClipboard(projectEntryPath(root, entry.relativePath)) }, + { label: 'Copy relative path', onClick: () => window.api.writeClipboard(entry.relativePath) }, + { sep: true }, + { label: 'Rename…', icon: PICONS.pencil(14), onClick: run('rename') }, + { label: 'Move to ' + trash + '…', icon: PICONS.trash(14), danger: true, onClick: run('trash') }, + ]; +} + +function bindFileEntryMenu(row, root, entry, open) { + row.addEventListener('contextmenu', event => { + event.preventDefault(); + event.stopPropagation(); + showContextMenu(fileEntryMenuItems(root, entry, open), { x: event.clientX, y: event.clientY }); + }); + row.addEventListener('keydown', event => { + if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return; + event.preventDefault(); + event.stopPropagation(); + showContextMenu(fileEntryMenuItems(root, entry, open), { anchor: row }); + }); +} diff --git a/public/file-panel.js b/public/file-panel.js index 7e9b1d76..69051118 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -332,6 +332,7 @@ function openFileTab(sessionId, data) { label: basename(data.filePath), filePath: data.filePath, content: data.content, + preview: data, }; state.panelVisible = true; @@ -359,10 +360,27 @@ function destroyCurrentTab(state) { } } -async function openFileInPanel(sessionId, filePath) { - const result = await window.api.readFileForPanel(filePath); - if (!result.ok) return; - openFileTab(sessionId, { filePath, content: result.content }); +async function openFileInPanel(sessionId, filePath, location = {}) { + const state = getSessionState(sessionId); + if (state.currentTab?.type === 'diff' && !state.currentTab.resolved) { + alert('Resolve or close the current diff before opening a file.'); + return; + } + const request = state.fileOpenRequest = (state.fileOpenRequest || 0) + 1; + // Clicking another reference to the same file keeps its unsaved edits. + if (currentPanelSessionId === sessionId && state.currentTab?.type === 'file' && fpViewerPanel.filePath === filePath) { + if (location.line) fpViewerPanel.goToLocation(location.line, location.column); + showPanel(state); + return; + } + const previousTab = state.currentTab; + let result; + try { result = await window.api.readFileForPanel(filePath); } catch (err) { result = { ok: false, error: err.message }; } + const effectiveSessionId = sessionIdForState(state, sessionId); + if (!effectiveSessionId || currentPanelSessionId !== effectiveSessionId || state.fileOpenRequest !== request || state.currentTab !== previousTab) return; + if (await openUnsupportedFile(result, filePath)) return; + if (!result?.ok) { alert(result?.error || 'Unable to open the file.'); return; } + openFileTab(effectiveSessionId, { ...result, filePath, line: location.line, column: location.column }); } async function openProjectFile(sessionId, relativePath) { @@ -380,9 +398,10 @@ async function openProjectFile(sessionId, relativePath) { renderProjectBrowser(sessionId); const result = await window.api.readProjectFile(requestedRoot, relativePath); - if (state.projectPath !== requestedRoot) return; + if (state.projectPath !== requestedRoot || state.selectedPath !== relativePath) return; const effectiveSessionId = sessionIdForState(state, sessionId); if (!effectiveSessionId) return; + if (await openUnsupportedFile(result, projectEntryPath(requestedRoot, relativePath), requestedRoot)) return; if (!result.ok) { state.selectedPath = ''; state.browserError = result.error || 'Unable to preview file'; @@ -390,7 +409,7 @@ async function openProjectFile(sessionId, relativePath) { return; } - openFileTab(effectiveSessionId, { filePath: result.filePath, content: result.content }); + openFileTab(effectiveSessionId, result); } function closeAllDiffs(sessionId) { @@ -509,7 +528,7 @@ function renderTabContent(sessionId, tab) { // Use ViewerPanel diffContainer.style.display = 'none'; vpContainer.style.display = 'flex'; - fpViewerPanel.open(tab.label, tab.filePath, tab.content); + fpViewerPanel.open(tab.label, tab.filePath, tab.content, tab.preview); } else { // Diff mode vpContainer.style.display = 'none'; @@ -778,12 +797,67 @@ function loadProjectDirectory(sessionId, state, relativePath) { }); } +function canManageBrowserEntry(root, relativePath) { + const change = { projectPath: root, relativePath, filePath: projectEntryPath(root, relativePath) }; + return ![...filePanelState.values()].some(state => state.currentTab?.type === 'diff' && + !state.currentTab.resolved && remapFileActionPath(state.currentTab.filePath, change) === null); +} + +async function applyBrowserFileAction(change) { + for (const [sessionId, state] of filePanelState) { + state.directoryCache.clear(); + state.loadingDirectories.clear(); + state.browserError = ''; + state.browserGeneration++; + state.selectedPath = remapFileActionRelative(state.projectPath, state.selectedPath, change) || ''; + state.expandedPaths = new Set([...state.expandedPaths].map(rel => remapFileActionRelative(state.projectPath, rel, change)).filter(Boolean)); + const tab = state.currentTab; + if (!tab) continue; + const next = remapFileActionPath(tab.filePath, change); + if (next === tab.filePath) continue; + if (!next) { + if (sessionId === currentPanelSessionId || tab.type === 'diff') destroyCurrentTab(state); + state.currentTab = null; + state.panelVisible = state.explorerVisible; + } else { + tab.filePath = next; + tab.label = basename(next); + if (tab.type === 'file') { + tab.preview = { ...tab.preview, filePath: next }; + if (sessionId === currentPanelSessionId) { + const result = await fpViewerPanel.relocate(tab.label, next); + if (state.currentTab === tab && fpViewerPanel.filePath === next) { + tab.content = fpViewerPanel.getContent(); + if (result?.ok) tab.preview = { ...result, previewType: fpViewerPanel.previewType }; + } + } else { + // Refresh asset URLs for a background HTML/media preview too. + let result; + try { result = await window.api.readFileForPanel(next); } catch {} + if (state.currentTab === tab && tab.filePath === next && result?.ok) tab.preview = result; + } + } else if (sessionId === currentPanelSessionId) { + renderDiffContent(sessionId, tab); + } + } + } + if (currentPanelSessionId) { + renderProjectBrowser(currentPanelSessionId); + const state = filePanelState.get(currentPanelSessionId); + if (!state?.currentTab) { + renderTabContent(currentPanelSessionId, null); + if (!state?.panelVisible) hidePanel(); + } + } +} + function appendTreeEntries(sessionId, state, container, entries, depth) { for (const entry of entries) { const row = document.createElement('button'); row.className = 'project-file-tree-row'; row.style.paddingLeft = `${8 + depth * 14}px`; row.dataset.path = entry.relativePath; + bindFileEntryMenu(row, state.projectPath, entry, () => openProjectFile(sessionId, entry.relativePath)); if (entry.type === 'directory') { const expanded = state.expandedPaths.has(entry.relativePath); @@ -818,11 +892,11 @@ function appendTreeEntries(sessionId, state, container, entries, depth) { row.classList.add('file'); row.classList.toggle('selected', state.selectedPath === entry.relativePath); - row.disabled = !entry.viewable; - row.title = entry.viewable ? entry.relativePath : 'This file type cannot be previewed'; + row.classList.toggle('preview-unavailable', entry.type !== 'file'); + row.title = entry.viewable ? entry.relativePath : entry.type === 'file' ? entry.relativePath + '\nOpen in default application' : 'This item cannot be opened'; row.innerHTML = '📄'; row.querySelector('.project-file-tree-name').textContent = entry.name; - if (entry.viewable) row.addEventListener('click', () => openProjectFile(sessionId, entry.relativePath)); + if (entry.type === 'file') row.addEventListener('click', () => openProjectFile(sessionId, entry.relativePath)); container.appendChild(row); } } diff --git a/public/index.html b/public/index.html index 29d76612..777b7eae 100644 --- a/public/index.html +++ b/public/index.html @@ -6,6 +6,7 @@ Switchboard +
@@ -13,11 +14,13 @@ + + + +
+