diff --git a/CHANGELOG.md b/CHANGELOG.md index 96786079..74f1c521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # aicodeman +## 1.9.8 + +### Patch Changes + +- **Fixed: sessions failed to start on macOS with `Error: posix_spawnp failed.`** (issues #6 and #204) + + `node-pty@1.1.0` publishes its macOS prebuilt helper as `prebuilds/darwin-/spawn-helper` with mode 0644, i.e. no execute bit. macOS launches every PTY through that helper, so a stock install failed on every session start. The bug is macOS-only: `spawn-helper` is a mac-only gyp target and node-pty ships no Linux prebuild, so Linux always compiles a correctly-permissioned helper from source. + + The previous fix chmodded only `build/Release/spawn-helper`, which on macOS does not exist (the prebuild is used, so node-gyp never runs), and it derived that path from `require.resolve('node-pty')`, landing on `/lib/build/Release/...`. It was a no-op on every platform. + - New `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every `spawn-helper` it finds, in `build/Release`, `build/Debug` and each `prebuilds/*/`, then verifies the result by actually opening a PTY. A `require()` alone passes on a broken install, because the helper is only touched at spawn time. + - `postinstall` no longer force-rebuilds node-pty from source on Node 22+. That step needed Xcode command line tools, cost 30-120s on every install, and deleted the `prebuilds/` tree before compiling, so a Mac without a compiler was left with no working binary at all. A rebuild now happens only when the chmod plus spawn probe still fails, and the prebuilds tree is backed up and restored around it. + - New `spawnPtyWithHelperRepair()` (`src/utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts`, so an install that is already broken repairs itself on the first failed spawn and retries in-process instead of showing a dead session. Unrelated spawn errors are rethrown untouched; a second failure carries the `npm run fix:node-pty` hint. + - `scripts/fix-node-pty.mjs` is now in the published `files` list, so global npm installs get the repair too. + - Direct-PTY Claude spawns use the resolved absolute binary path (new `getClaudeBinaryPath()`) instead of the bare name `claude`, so a CLI installed outside the server's PATH still launches. + + Verified end to end on macOS 26.4 arm64: a stock `npm i` reproduces `posix_spawnp failed.`, and after the fix the same install spawns a PTY successfully with the prebuilds preserved. + + **Added: phone home screen (session overview)** + + Under 430px the "C" logo now opens a session overview (current sessions, past sessions, spaces) instead of the welcome overlay: on a small screen "which session needs me" beats "how do I start one". Rows resume a session in place, and "New session here" goes through the normal quick-start path so remote and Docker cases keep their routing. Per-device setting `mobileOverviewEnabled` (phones only, default ON) in App Settings. Tablet and desktop are unchanged. + + **Added: guided Tailscale setup in `install.sh`** + + The network-access prompt is now 3-way: Tailscale, LAN, or local-only. The Tailscale path binds loopback and walks through installing Tailscale, logging in, the operator grant, the tailnet HTTPS-certificates toggle, and `tailscale serve --bg `, then verifies the result end to end with curl. That gives HTTPS on a real certificate with no app password and no `0.0.0.0` bind, which is also what PWA install and web push need. `install.sh tailscale` retrofits it onto an existing install, and `CODEMAN_TAILSCALE=1` presets the choice. Serve state is detected from `tailscale serve status --json`; the installer never runs `tailscale serve reset` and never touches serve mappings other than 443 to Codeman's port. README and `docs/security-architecture.md` updated to match. + + **Docs**: replaced a real tailnet hostname with placeholders in `docs/web-tabs-fixes-plan.md`. + + **xterm-zerolag-input**: npm description and keywords only, no code change. + ## 1.9.7 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 700fe52f..bc5d3c6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.9.7 (must match `package.json`) +**Version**: 1.9.8 (must match `package.json`) ## Project Overview @@ -130,6 +130,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **`xterm-zerolag-input` is single-source** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/`, and is bundled into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` (dev, by `scripts/postinstall.js`) and `dist/.../vendor/` (prod, by `scripts/build.mjs`). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundle.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md` - **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md` - **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger) +- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in **`prebuilds/-/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) - **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture) **Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files. @@ -157,7 +158,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases | | **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | -| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 23 modules + `sw.js` | See Frontend section for the load order, which is authoritative | +| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 25 modules + `sw.js` | See Frontend section for the load order, which is authoritative | | **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -229,10 +230,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. +**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere). + **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry) **Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. @@ -354,6 +357,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer ## Scripts & Tunnel -**`install.sh`** (repo root, 69KB) is the public entry point: `curl -fsSL | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. It prompts for the network binding (LAN default + password prompt) and preserves the existing binding on re-runs via `read_existing_binding()`. `install.sh update` and `install.sh uninstall` also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation. +**`install.sh`** (repo root, 69KB) is the public entry point: `curl -fsSL | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. The network-access prompt is 3-way: **Tailscale** (loopback bind + guided `tailscale serve --bg ` HTTPS setup: install/login/operator/tailnet-HTTPS-toggle, then curl-verified end-to-end), **LAN** (0.0.0.0 + password prompt), or **local-only**; it preserves the existing binding on re-runs via `read_existing_binding()`. Tailscale state is detected dynamically from `tailscale serve status --json` (no marker files); the installer must NEVER `tailscale serve reset` or touch serve mappings other than 443→Codeman's port (users have unrelated serve config). `install.sh update`, `install.sh uninstall`, and `install.sh tailscale` (retrofit Tailscale access onto an existing install) also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation, `CODEMAN_TAILSCALE=1` presets the Tailscale choice (never installs Tailscale non-interactively). Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel. diff --git a/README.md b/README.md index 6333a9a4..ec44b382 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ codeman web --https # Open on your phone: https://:3000 ``` -> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended) — it provides a private network so you can access `http://:3000` from your phone without TLS certificates. +> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://..ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone. ### Secure QR Code Authentication diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3eee57b5..d0dfd805 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -206,6 +206,16 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs` (the `xterm-zerolag-input` esbuild step, for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`. +### node-pty's macOS spawn-helper must be executable + +**node-pty ships its macOS `spawn-helper` non-executable, which breaks every session start on macOS** (issues #6 and #204, fixed properly in 1.9.8). `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` with mode **0644**. On macOS node-pty launches every PTY through that helper (`argv[0] = helper_path` → `pty_posix_spawn()` in `src/unix/pty.cc`, guarded by `#if defined(__APPLE__)`), so a helper without `+x` makes `posix_spawnp` fail EACCES and node-pty throws `Error: posix_spawnp failed.`, surfaced by Codeman as "Failed to start Claude: …". It is **macOS-exclusive twice over**: `spawn-helper` is an `OS=="mac"` gyp target, and node-pty ships prebuilds for darwin + win32 only, so Linux always compiles from source (node-gyp emits an executable helper) and can never reproduce it. That asymmetry is why it kept coming back: a Linux dev box shows nothing wrong. + +⚠️ **Look in `prebuilds/-/`, not just `build/Release/`.** node-pty's loader (`lib/utils.js`) tries `build/Release`, `build/Debug`, then `prebuilds/-`, and `unixTerminal.js` derives `helperPath` from **whichever directory the native module loaded out of**. The 0.15 fix only chmodded `build/Release/spawn-helper`, which on macOS does not exist at all (the prebuild is used, so node-gyp never runs), and it resolved the path off `require.resolve('node-pty')` (= `/lib/index.js`), producing `/lib/build/Release/spawn-helper`, so it was a no-op on every platform. + +The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every helper it finds, then **proves** the result by actually opening a PTY, because a `require()` alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs `prebuilds/` up first: node-pty's install script `rmSync`s the whole prebuilds tree the moment `npm_config_build_from_source` is set and only *then* shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in `postinstall` for that reason (it also cost 30-120s on every install). + +`src/utils/node-pty-repair.ts` is the runtime safety net for installs that are already broken: all four `pty.spawn()` calls in `session.ts` go through `spawnPtyWithHelperRepair()`, which chmods and retries **once** on a `posix_spawnp`/`spawn-helper` error and rethrows anything else untouched, so the user never sees a dead session. A second failure rethrows with the `npm run fix:node-pty` hint attached instead of a bare "posix_spawnp failed". The repair is attempted at most once per process (no chmod storms). + ## Tooling traps ### Headless screenshot capture diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 85a23ad4..b30e6deb 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -249,16 +249,28 @@ Ordered most‑to‑least recommended: ### A. Tailscale serve (recommended) -Bind loopback, let Tailscale front it on your tailnet with a real cert: +Bind loopback, let Tailscale front it on your tailnet with a real cert. **The +installer sets this up for you**: choose **Tailscale** at the network-access +prompt, or retrofit an existing install with: ```bash -codeman web --https # binds 127.0.0.1:3000 -tailscale serve --bg https / http://127.0.0.1:3000 +bash ~/.codeman/app/install.sh tailscale ``` -Only devices on your tailnet can reach it; Tailscale handles identity. No app -password and no `0.0.0.0` bind required. (This is the maintainer's production -setup.) +The guided flow installs Tailscale if needed, walks through login and the +tailnet HTTPS-certificates toggle, and configures the equivalent of: + +```bash +codeman web # binds 127.0.0.1:3000 (plain HTTP is fine here) +tailscale serve --bg 3000 # HTTPS at https://..ts.net +``` + +Only devices on your tailnet can reach it; Tailscale handles identity and +terminates TLS with a real Let's Encrypt certificate (so PWA install and web +push work). No app password and no `0.0.0.0` bind required. (This is the +maintainer's production setup.) `CODEMAN_TAILSCALE=1` presets the choice for +automation; the installer never runs `tailscale serve reset` and never touches +serve mappings other than `443 -> Codeman's port`. ### B. Authenticated cloudflared tunnel + password diff --git a/docs/tailscale-installer-plan.md b/docs/tailscale-installer-plan.md new file mode 100644 index 00000000..b83a5482 --- /dev/null +++ b/docs/tailscale-installer-plan.md @@ -0,0 +1,236 @@ +# Tailscale Setup in the Installer (Plan) + +Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in +`install.sh`, instead of a one-line hint pointing at the docs. Today the safest +recommended deployment (loopback bind + `tailscale serve`) is exactly what the +maintainer's own prod runs, but a new user has to discover and wire it by hand. +The installer should do it for them. + +Status: IMPLEMENTED (2026-08-04). `install.sh` carries the 3-way network +prompt, the guided Tailscale flow, and the `tailscale` subcommand; README, +`docs/security-architecture.md` section A, and CLAUDE.md are updated. Verified +live on the maintainer's prod host: `install.sh tailscale` took the idempotent +kept-as-is path against the existing serve mapping (recognizing the legacy +`https+insecure://` target), verified `https://.ts.net/api/status` +end-to-end, and left `tailscale serve status` byte-identical. Items 1-4, 7, +and 10-12 of the manual matrix below still need a fresh machine to exercise. + +## Why this is low-hanging fruit + +Everything on the app side already works; this is almost purely installer UX: + +- `.ts.net` is already in `DEFAULT_TRUSTED_HOST_SUFFIXES` + (`src/web/network-auth-policy.ts`), so the always-on Host/Origin guard accepts + `tailscale serve` traffic with zero configuration. No `CODEMAN_ALLOWED_HOSTS` + needed. +- The loopback bind is the server default and prints no warning; nothing to + acknowledge, no `CODEMAN_PASSWORD` strictly required (the tailnet is the auth + boundary; Tailscale authenticates the device before a packet ever reaches us). +- `tailscale serve` terminates TLS with a real Let's Encrypt certificate for + `..ts.net`. That gives users valid HTTPS with no self-signed + cert warnings, and (because it is a proper secure context) working service + worker, PWA install, and web push on phones. This is strictly better than + `codeman web --https` for remote access. +- SSE and WebSockets work through serve (proven by prod: + `https://tnode.tailf80371.ts.net` fronting `127.0.0.1:3000` daily). +- `docs/security-architecture.md` section "A. Tailscale serve (recommended)" + already documents this as the preferred setup; the installer just does not + implement it. + +## UX design + +### 1. The network-access prompt grows a Tailscale option + +`choose_network_binding()` (install.sh:1051) currently offers two choices. New +menu, with Tailscale first when it can be recommended: + +``` + Network access + + How should the Codeman dashboard be reachable? + + 1) Tailscale (recommended) + Private VPN access from your phone/laptop, real HTTPS, + no password needed. Works from anywhere, not just your Wi-Fi. + 2) Any device on your network (0.0.0.0) + Open it straight from your phone or laptop on the same Wi-Fi. + Less safe: set a password so only you control your agents. + 3) This machine only (127.0.0.1) + Safest. Reach it remotely via Tailscale or a tunnel later. +``` + +Choice mapping: + +- Option 1 = bind `127.0.0.1` (unchanged server posture) + configure + `tailscale serve`. Internally it is option 3 plus the serve setup, so all + existing binding plumbing (`BIND_HOST`, service files, `read_existing_binding`) + is untouched. +- Options 2 and 3 behave exactly as today (renumbered). +- Default choice: 1 when tailscale is installed and logged in, or when an + existing serve mapping for our port is detected; otherwise keep today's + defaults (1 -> 2, 2 -> 3 renumbering, preserving the "existing setup wins" + rule). If tailscale is not installed, option 1 is still shown (the installer + offers to install it), but the default stays on the current behavior so a + bare Enter never pulls in new software. +- Password: after choosing Tailscale, offer the password prompt as optional + defense in depth with default skip ("the tailnet already authenticates your + devices; add one anyway?"). No `BIND_ACK` needed since the bind is loopback. + +### 2. The Tailscale flow (state machine) + +New `setup_tailscale_access()` runs after the binding choice, before service +setup, handling each state in order: + +1. **Not installed.** + - Linux: offer to run the official installer + (`curl -fsSL https://tailscale.com/install.sh | sh`), which handles all + distros and enables `tailscaled` at boot. This mirrors our own + curl-pipe-bash story and avoids maintaining per-distro logic like the six + `install_cloudflared_*` functions. + - macOS: do not auto-install (the GUI app needs an interactive login). + Offer `brew install --cask tailscale` when brew exists, else print the + download link, then wait-and-retry or let the user skip. + - Declined install => fall back to plain loopback (option 3 behavior) and + print how to redo this later (`install.sh tailscale`, see below). +2. **Installed but logged out** (`tailscale status --json` -> + `.BackendState == "NeedsLogin"` or `"Stopped"`). + - Run `tailscale up` (via `run_as_root` if needed). It prints an auth URL + that works headless (user opens it on any device). Poll + `.BackendState == "Running"` with a friendly spinner + timeout; on + timeout, skip gracefully with re-run instructions. +3. **Running: grant operator (Linux).** `sudo tailscale set --operator=$USER` + so serve configuration (now and in the future) does not need root. Skip + silently if we are already operator (probe: `tailscale serve status` + exits 0) or sudo is declined; fall back to `run_as_root tailscale serve ...`. +4. **HTTPS availability check.** `.CertDomains` empty or + `.CurrentTailnet.MagicDNSEnabled == false` means the tailnet has not enabled + MagicDNS / HTTPS certificates. Print the exact two toggles with the admin + URL (https://login.tailscale.com/admin/dns: enable MagicDNS, then enable + HTTPS Certificates), then offer "I enabled it, re-check" / "skip for now". + No silent HTTP fallback: the pitch is real HTTPS, and a plain-HTTP serve + would break the PWA/push story. Skipping falls back to loopback + re-run + instructions. +5. **Existing serve config check** (`tailscale serve status --json`). + - Already proxying to our port (443 -> `127.0.0.1:$PORT`): keep it, report + it, done. Re-running the installer must be idempotent. + - Port 443 occupied by a DIFFERENT target: never clobber it. Ask whether to + replace it or skip. (Prod itself has a second serve on :5000; blind + `tailscale serve reset` would destroy user config. NEVER use `reset`.) +6. **Configure.** `tailscale serve --bg $PORT` where `$PORT` is the install's + Codeman port (default 3000; honor a preset `CODEMAN_PORT`). Serve targets + plain HTTP on loopback; TLS terminates at tailscaled with the real cert. + The `--bg` config persists in tailscaled state across reboots, so no extra + service unit is needed. + (Note: do NOT combine this with `codeman web --https`; that is what forces + the awkward `https+insecure://` proxy target prod historically used. New + installs should keep Codeman on plain HTTP behind serve.) +7. **Verify end-to-end.** Derive the URL from `.Self.DNSName` (strip the + trailing dot) and curl `https:///api/status` after the service is + up, retrying for ~30s: the first request can be slow while the Let's + Encrypt cert is issued. Print success with the URL, or the observed error + with `tailscale serve status` output on failure. This follows the "always + test before claiming it works" rule; a blind "done!" is not acceptable. + +### 3. Closing summary and security notice + +- The final summary gains a "Remote Access (Tailscale)" block, printed above + the cloudflared block, showing the actual URL: + + ``` + Remote Access (Tailscale): + https://tnode.tailf80371.ts.net (any device on your tailnet, HTTPS) + tailscale serve status # inspect + ``` + +- `print_security_notice()` third branch (loopback) gets a variant: when a + serve mapping for our port is detected, lead with "reachable on your tailnet + at https://... (HTTPS, tailnet-only)" instead of the generic "do ONE of" + list. Detection is dynamic (query `tailscale serve status --json` at print + time), no marker persisted anywhere: tailscaled's own state is the single + source of truth, so external changes never drift against a stale flag. + +### 4. Standalone entry point: `install.sh tailscale` + +Add a `tailscale` subcommand next to `update` / `uninstall` in the existing +dispatch. It runs `setup_tailscale_access()` against the already-installed +service (reads the port from the service file, requires an existing install). +This serves: + +- existing installs that predate the feature, +- users who picked "this machine only" and changed their mind, +- every "skip for now" branch above, all of which print this exact command. + +One implementation, two entry points. No separate `scripts/tailscale-setup.sh` +(unlike cloudflared, there is no long-running process for a `tunnel.sh`-style +start/stop wrapper to manage; tailscaled owns the lifecycle). + +### 5. Non-interactive / automation + +- `CODEMAN_TAILSCALE=1` presets choice 1 (analogous to presetting + `CODEMAN_HOST`). In non-interactive runs it only proceeds through states + that need no human (already installed + logged in + HTTPS-enabled tailnet); + anything requiring interaction (login URL, admin-console toggle, replacing a + foreign serve mapping) warns and falls back to loopback. It never installs + tailscale non-interactively. +- `CODEMAN_NONINTERACTIVE=1` with an existing serve mapping: preserve it, same + "never silently loosen/change" policy as `read_existing_binding`. +- Document both in the header comment block of install.sh (the env-var + reference at the top) and in the README. + +## Edge cases and decisions + +| Case | Decision | +| ---- | -------- | +| macOS GUI app without `tailscale` on PATH | `get_tailscale_path()` helper mirroring `get_cloudflared_path()`: check PATH, then `/Applications/Tailscale.app/Contents/MacOS/Tailscale`. All calls go through it. | +| Tailnet HTTPS certs disabled | Guided admin-console instructions + re-check loop; skip falls back to loopback. Never configure plain-HTTP serve. | +| Port 443 serve exists for another app | Prompt replace/skip; never `tailscale serve reset` (destroys unrelated mappings). | +| First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. | +| `tailscale up` needs auth | Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. | +| Custom `CODEMAN_PORT` | Serve target uses the actual port; `install.sh tailscale` re-reads it from the service file. | +| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without `CODEMAN_PASSWORD` (`isUnauthenticatedNetworkAcknowledged`). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention `tailscale funnel` in docs only, with the password warning. | +| Uninstall | Best effort: if `serve status --json` shows 443 proxying to our port, run the targeted `tailscale serve --https=443 off` (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. | +| User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). | + +## What does NOT change + +- Server code: no changes required. Host guard already trusts `.ts.net`, + loopback bind is already the default, SSE/WS already work through serve. +- The two existing binding options and their semantics, `read_existing_binding` + preservation, and the LAN+password flow. +- `scripts/tunnel.sh` / cloudflared support (stays as the "no Tailscale + account" alternative). +- The security model: this feature only ever narrows exposure (loopback + + authenticated overlay), never widens it. + +## Files touched (implementation inventory) + +| File | Change | +| ---- | ------ | +| `install.sh` | New: `check_tailscale`, `get_tailscale_path`, `tailscale_status_field` (jq-free JSON field extraction; the installer cannot assume jq: use `sed`/`grep` like existing helpers or `tailscale status --json` piped to `node -e` since node is guaranteed post-install), `offer_install_tailscale`, `ensure_tailscale_login`, `ensure_tailscale_operator`, `ensure_tailnet_https`, `setup_tailscale_serve`, `verify_tailscale_access`, `setup_tailscale_access` (orchestrator). Modified: `choose_network_binding` (3-way menu), summary block, `print_security_notice`, subcommand dispatch (`tailscale`), `uninstall` (targeted serve removal), header env-var docs (`CODEMAN_TAILSCALE`). | +| `README.md` | Remote-access section: promote the Tailscale path with the one-liner and `install.sh tailscale`; keep the tailscale-IP HTTP note for non-serve users but recommend serve + HTTPS. | +| `docs/security-architecture.md` | Section A gains "the installer can set this up for you" + `install.sh tailscale` pointer. | +| `CLAUDE.md` | One line in Scripts & Tunnel: installer offers Tailscale setup (`install.sh tailscale` to redo). | +| `test/` | No unit tests possible for interactive bash + a live tailnet; guard with `shellcheck install.sh` (already the norm) and the manual matrix below. | + +## Manual test matrix (before release) + +1. Linux + tailscale absent: install offered, declined => loopback fallback + hint. +2. Linux + tailscale absent: install accepted => full flow => URL verified. +3. Logged out => auth URL flow => Running => serve configured. +4. Tailnet with HTTPS certs disabled => guided instructions => re-check => success; and the skip branch. +5. Re-run installer with serve already configured => idempotent, preserved, reported. +6. Second serve mapping on another port present => untouched (prod-like state). +7. Port 443 already proxying another target => replace/skip prompt honored. +8. `install.sh tailscale` on an existing loopback install (the retrofit path). +9. `CODEMAN_NONINTERACTIVE=1` re-run => preserves everything, no prompts. +10. macOS (Mac mini `arbbot` box): GUI-app CLI path detection + full flow. +11. Uninstall removes only our 443 mapping, leaves others. +12. Phone check: PWA install + push from the `https://*.ts.net` origin. + +## Release + +Changeset: `minor` (new documented installer capability + new `CODEMAN_TAILSCALE` +env var). The feature is installer-only, so it ships with zero risk to running +servers; `install.sh update` does not invoke the new flow (updates never rewrite +access config), only fresh installs and the explicit `install.sh tailscale` +subcommand do. diff --git a/docs/web-tabs-fixes-plan.md b/docs/web-tabs-fixes-plan.md index 9366dbf2..6d9373c4 100644 --- a/docs/web-tabs-fixes-plan.md +++ b/docs/web-tabs-fixes-plan.md @@ -1,7 +1,7 @@ # Web tabs: two fixes (planned + implemented 2026-07-28) Both found against the saved dashboard -`https://macminis-mac-mini.tailf80371.ts.net:4000` (Bio-Hacking-Dashboard). +`https://..ts.net:4000` (Bio-Hacking-Dashboard). Kept because the root-cause analysis of the second one is not obvious from the resulting diff. @@ -49,7 +49,7 @@ already revoked the capability and broadcast `WebviewChanged`. ``` CAP=/open> # A) upstream direct -> 200 image/jpeg 118150 -curl -sk "https://macminis-mac-mini.tailf80371.ts.net:4000/api/hero?slug=120-minutes-in-nature" +curl -sk "https://..ts.net:4000/api/hero?slug=120-minutes-in-nature" # B) through the proxy prefix -> 200 image/jpeg 118150 curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature" # C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"} diff --git a/install.sh b/install.sh index bfa256c5..807e78a0 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,16 @@ # non-interactive default is 127.0.0.1) # CODEMAN_PASSWORD - Preset the dashboard password (skips the # password prompt when binding to the network) +# CODEMAN_TAILSCALE=1 - Preset the Tailscale choice: bind loopback and +# front it with `tailscale serve` HTTPS (skips +# the network prompt; never installs Tailscale +# in non-interactive runs) +# +# Subcommands: +# install.sh update - Update an existing install +# install.sh uninstall - Remove services, symlinks and (optionally) data +# install.sh tailscale - Set up (or repair) Tailscale serve HTTPS access +# for an existing install set -euo pipefail @@ -51,6 +61,14 @@ EXISTING_HOST="" EXISTING_PASSWORD="" EXISTING_ACK="0" +# Tailscale serve URL configured or detected during this run +# (setup_tailscale_access / detect_tailscale_serve_url). Empty when the +# Tailscale path was not taken or not completed. +TAILSCALE_SERVE_URL="" +# Set to 1 when serve commands must go through sudo because granting the user +# tailscale "operator" rights failed (ensure_tailscale_operator). +TS_NEED_ROOT="0" + # puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its # ~150MB chrome-headless-shell download is never needed to build or run Codeman. # Skipping it avoids a slow download and a fatal install failure when a prior @@ -177,13 +195,28 @@ print_security_notice() { echo -e " For access from OUTSIDE your network, prefer Tailscale or a tunnel." echo -e " ${DIM}Details: docs/security-architecture.md${NC}" else - echo -e " ${YELLOW}${BOLD}Security:${NC}" - echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default." - echo -e " To reach it from another device, do ONE of:" - echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or" - echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}" - echo -e " A non-loopback bind without a password still starts, but warns loudly." - echo -e " ${DIM}Details: docs/security-architecture.md${NC}" + # Loopback bind: when a tailscale serve mapping fronts it, lead with + # the actual URL instead of the generic "do ONE of" list. Detection is + # dynamic (tailscaled state is the single source of truth). + local notice_ts_url="$TAILSCALE_SERVE_URL" + if [[ -z "$notice_ts_url" ]]; then + notice_ts_url=$(detect_tailscale_serve_url 2>/dev/null) || notice_ts_url="" + fi + if [[ -n "$notice_ts_url" ]]; then + echo -e " ${YELLOW}${BOLD}Security:${NC}" + echo -e " Codeman binds ${BOLD}127.0.0.1${NC}, fronted by Tailscale serve:" + echo -e " reachable at ${BOLD}$notice_ts_url${NC} (HTTPS, your tailnet only)." + echo -e " Tailscale authenticates every device before traffic reaches Codeman." + echo -e " ${DIM}Details: docs/security-architecture.md${NC}" + else + echo -e " ${YELLOW}${BOLD}Security:${NC}" + echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default." + echo -e " To reach it from another device, do ONE of:" + echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or" + echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}" + echo -e " A non-loopback bind without a password still starts, but warns loudly." + echo -e " ${DIM}Details: docs/security-architecture.md${NC}" + fi fi echo "" } @@ -1050,6 +1083,7 @@ read_existing_binding() { # preset. The server binary itself still defaults to 127.0.0.1 either way. choose_network_binding() { # Preset via environment: honor it and skip the prompt entirely. + # CODEMAN_TAILSCALE=1 composes with a loopback (or absent) CODEMAN_HOST. if [[ -n "${CODEMAN_HOST:-}" ]]; then BIND_HOST="$CODEMAN_HOST" BIND_PASSWORD="${CODEMAN_PASSWORD:-}" @@ -1057,6 +1091,20 @@ choose_network_binding() { BIND_ACK="1" fi info "Network binding preset via CODEMAN_HOST: $BIND_HOST" + if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then + if [[ "$BIND_HOST" == "127.0.0.1" ]]; then + setup_tailscale_access || true + else + warn "CODEMAN_TAILSCALE=1 ignored: CODEMAN_HOST=$BIND_HOST is not loopback." + fi + fi + return 0 + fi + if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then + BIND_HOST="127.0.0.1" + BIND_PASSWORD="${CODEMAN_PASSWORD:-}" + info "Tailscale access preset via CODEMAN_TAILSCALE=1" + setup_tailscale_access || true return 0 fi @@ -1077,21 +1125,48 @@ choose_network_binding() { return 0 fi - # Default follows the existing setup when there is one, else network. - local default_choice="1" + # Tailscale state, for the menu hint and the default choice. Detection + # only; never installs, logs in, or prompts for sudo here. + local ts_hint="will be installed for you" ts_ready="0" ts_detected_url="" + if check_tailscale; then + ts_hint="installed, needs login" + if command -v node &>/dev/null && [[ "$(ts_status_field 's.BackendState')" == "Running" ]]; then + ts_ready="1" + ts_hint="already connected" + ts_detected_url=$(detect_tailscale_serve_url) || ts_detected_url="" + if [[ -n "$ts_detected_url" ]]; then + ts_hint="already serving Codeman" + fi + fi + fi + + # Defaults: an existing setup wins (existing loopback installs default to + # Tailscale only when its serve mapping is already present); fresh installs + # default to Tailscale when it is already connected, else network access. + # A bare Enter never pulls in new software. + local default_choice="2" if [[ "$EXISTING_FOUND" == "1" && "$EXISTING_HOST" == "127.0.0.1" ]]; then - default_choice="2" + if [[ -n "$ts_detected_url" ]]; then + default_choice="1" + else + default_choice="3" + fi + elif [[ "$EXISTING_FOUND" != "1" && "$ts_ready" == "1" ]]; then + default_choice="1" fi echo -e " ${BOLD}Network access${NC}" echo "" echo -e " How should the Codeman dashboard be reachable?" echo "" - echo -e " ${CYAN}1)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC}" - echo -e " Open it straight from your phone or laptop." + echo -e " ${CYAN}1)${NC} ${BOLD}Tailscale${NC} ${DIM}($ts_hint)${NC}" + echo -e " Private VPN access from your phone or laptop, anywhere." + echo -e " Real HTTPS, no password needed: your tailnet is the login." + echo -e " ${CYAN}2)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC}" + echo -e " Open it straight from your phone or laptop on the same Wi-Fi." echo -e " ${YELLOW}Less safe: set a password so only you control your agents.${NC}" - echo -e " ${CYAN}2)${NC} ${BOLD}This machine only${NC} ${DIM}(127.0.0.1)${NC}" - echo -e " Safest. Reach it remotely via Tailscale or a tunnel." + echo -e " ${CYAN}3)${NC} ${BOLD}This machine only${NC} ${DIM}(127.0.0.1)${NC}" + echo -e " Safest. Reach it remotely via Tailscale or a tunnel later." echo "" if [[ "$EXISTING_FOUND" == "1" ]]; then echo -e " ${DIM}Current setup: $EXISTING_HOST$([[ -n "$EXISTING_PASSWORD" ]] && echo ", password set"). Enter keeps it.${NC}" @@ -1100,21 +1175,55 @@ choose_network_binding() { local bind_choice="" while true; do - echo -en "${CYAN}Choose [1/2] (default $default_choice):${NC} " >&2 + echo -en "${CYAN}Choose [1/2/3] (default $default_choice):${NC} " >&2 read_reply bind_choice || bind_choice="$default_choice" bind_choice="${bind_choice:-$default_choice}" case "$bind_choice" in - 1|2) break ;; - *) echo "Please enter 1 or 2." >&2 ;; + 1|2|3) break ;; + *) echo "Please enter 1, 2, or 3." >&2 ;; esac done - if [[ "$bind_choice" == "2" ]]; then + if [[ "$bind_choice" == "3" ]]; then BIND_HOST="127.0.0.1" success "Binding 127.0.0.1 (this machine only)" return 0 fi + if [[ "$bind_choice" == "1" ]]; then + BIND_HOST="127.0.0.1" + setup_tailscale_access || true + + # Password is optional here: the tailnet already authenticates devices. + # An existing password is always kept (never silently loosen). + if [[ -n "$EXISTING_PASSWORD" ]]; then + BIND_PASSWORD="$EXISTING_PASSWORD" + info "Keeping the existing dashboard password" + elif [[ -n "${CODEMAN_PASSWORD:-}" ]]; then + BIND_PASSWORD="$CODEMAN_PASSWORD" + info "Using CODEMAN_PASSWORD from the environment" + elif prompt_yes_no "Add a dashboard password too? (optional; your tailnet already authenticates your devices)" "n"; then + local ts_pw="" ts_pw2="" + while true; do + echo -en "${CYAN}Dashboard password:${NC} " >&2 + read_secret ts_pw || ts_pw="" + if [[ -z "$ts_pw" ]]; then + info "No password set" + break + fi + echo -en "${CYAN}Confirm password:${NC} " >&2 + read_secret ts_pw2 || ts_pw2="" + if [[ "$ts_pw" == "$ts_pw2" ]]; then + BIND_PASSWORD="$ts_pw" + success "Password set (login user: admin)" + break + fi + echo "Passwords do not match, try again." >&2 + done + fi + return 0 + fi + # Keep a custom non-loopback host from a previous install (e.g. a specific # interface IP); otherwise bind all interfaces. if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then @@ -1162,6 +1271,403 @@ choose_network_binding() { return 0 } +# ============================================================================ +# Tailscale Access (loopback bind fronted by `tailscale serve` HTTPS) +# ============================================================================ +# The recommended remote-access setup: Codeman stays on 127.0.0.1 and +# tailscaled fronts it with a real Let's Encrypt certificate for +# https://..ts.net, reachable from the user's tailnet only. +# The app side needs zero configuration (.ts.net is in the server's trusted +# host suffixes). All state lives in tailscaled: no marker files, `tailscale +# serve status` is the single source of truth, and `--bg` config persists +# across reboots on its own. +# +# Safety rule for every function here: NEVER `tailscale serve reset` and never +# touch mappings other than 443 -> Codeman's port. Users may have unrelated +# serve config (other ports, other apps) that a reset would destroy. + +get_tailscale_path() { + if command -v tailscale &>/dev/null; then + command -v tailscale + return 0 + fi + # macOS GUI app (App Store or brew cask) ships the CLI inside the bundle + # and does not put it on PATH. + if [[ -x "/Applications/Tailscale.app/Contents/MacOS/Tailscale" ]]; then + echo "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + return 0 + fi + return 1 +} + +check_tailscale() { + get_tailscale_path >/dev/null 2>&1 +} + +ts_cmd() { + local ts_bin + ts_bin=$(get_tailscale_path) || return 127 + "$ts_bin" "$@" +} + +# Serve mutations need root or "operator" rights on Linux; TS_NEED_ROOT is set +# by ensure_tailscale_operator when the operator grant failed. Detection paths +# run with TS_NEED_ROOT=0 and must never trigger a sudo prompt. +ts_cmd_serve() { + local ts_bin + ts_bin=$(get_tailscale_path) || return 127 + if [[ "$TS_NEED_ROOT" == "1" ]]; then + run_as_root "$ts_bin" "$@" + else + "$ts_bin" "$@" + fi +} + +# ts_status_field : evaluate an expression against the parsed +# `tailscale status --json` object bound to `s`, printing the result (empty on +# any error). node is guaranteed at every call site (the installer installs it +# before the binding prompt; the subcommand requires a completed install). +ts_status_field() { + ts_cmd status --json 2>/dev/null | node -e ' + let d = ""; + process.stdin.on("data", (c) => (d += c)); + process.stdin.on("end", () => { + try { + const s = JSON.parse(d); + const v = eval(process.argv[1]); + if (v !== undefined && v !== null && v !== false) process.stdout.write(String(v)); + } catch {} + }); + ' "$1" 2>/dev/null +} + +# Print the local port that the :443 web handler proxies to, empty when 443 is +# unconfigured. Any scheme counts (http://, and https+insecure:// from setups +# where Codeman itself runs --https), so legacy configs are recognized as ours. +ts_serve_443_target_port() { + ts_cmd_serve serve status --json 2>/dev/null | node -e ' + let d = ""; + process.stdin.on("data", (c) => (d += c)); + process.stdin.on("end", () => { + try { + const s = JSON.parse(d); + for (const [hostport, cfg] of Object.entries(s.Web || {})) { + if (!hostport.endsWith(":443")) continue; + const proxy = cfg && cfg.Handlers && cfg.Handlers["/"] && cfg.Handlers["/"].Proxy; + if (!proxy) continue; + const m = String(proxy).match(/:(\d+)\/?$/); + if (m) process.stdout.write(m[1]); + return; + } + } catch {} + }); + ' 2>/dev/null +} + +# Print https://..ts.net when tailscale is running AND serve +# already forwards 443 to Codeman's port; print nothing otherwise. Safe to call +# anywhere (no sudo, no side effects); used by the security notice, uninstall, +# and the re-run default. +detect_tailscale_serve_url() { + check_tailscale || return 0 + command -v node &>/dev/null || return 0 + [[ "$(ts_status_field 's.BackendState')" == "Running" ]] || return 0 + local port="${CODEMAN_PORT:-3000}" + [[ "$(ts_serve_443_target_port)" == "$port" ]] || return 0 + local dns + dns=$(ts_status_field 's.Self && s.Self.DNSName') + [[ -n "$dns" ]] || return 0 + echo "https://${dns%.}" +} + +tailscale_retrofit_hint() { + warn "$1: falling back to local-only access (127.0.0.1)." + echo -e " ${DIM}Set up Tailscale access any time later with:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}" >&2 +} + +offer_install_tailscale() { + if [[ "$NONINTERACTIVE" == "1" ]]; then + info "Tailscale is not installed; skipping (non-interactive runs never install it)." + return 1 + fi + headless_guard "install Tailscale (curl | sh from tailscale.com)" + + if [[ "$(uname -s)" == "Darwin" ]]; then + if command -v brew &>/dev/null; then + if ! prompt_yes_no "Tailscale is not installed. Install it now with Homebrew?" "y"; then + return 1 + fi + if ! brew install --cask tailscale; then + warn "Homebrew install failed." + return 1 + fi + open -a Tailscale 2>/dev/null || true + info "Log in via the Tailscale menu-bar app if it asks." + else + info "Install the Tailscale app first: https://tailscale.com/download/macos" + if ! prompt_yes_no "Continue once Tailscale is installed?" "n"; then + return 1 + fi + fi + else + if ! prompt_yes_no "Tailscale is not installed. Install it now (official installer from tailscale.com)?" "y"; then + return 1 + fi + info "Running the official Tailscale installer (it may ask for sudo)..." + # When piped (curl | bash), stdin is our pipe: give the child installer + # the real terminal so its own sudo prompt works. + if [[ -e /dev/tty ]]; then + if ! sh -c "$(download_to_stdout https://tailscale.com/install.sh)" < /dev/tty; then + warn "Tailscale installation failed." + return 1 + fi + else + if ! sh -c "$(download_to_stdout https://tailscale.com/install.sh)"; then + warn "Tailscale installation failed." + return 1 + fi + fi + fi + + if ! check_tailscale; then + warn "tailscale was not found after the install." + return 1 + fi + success "Tailscale installed" + return 0 +} + +ensure_tailscale_login() { + local state + state=$(ts_status_field 's.BackendState') + if [[ "$state" == "Running" ]]; then + return 0 + fi + if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then + warn "Tailscale is installed but not connected (state: ${state:-unknown})." + return 1 + fi + + info "Tailscale needs to log in to your tailnet." + echo -e " ${DIM}A login URL will be printed: open it on any device. Waiting up to 5 minutes.${NC}" + local ts_bin up_ok="0" + ts_bin=$(get_tailscale_path) || return 1 + if [[ "$(uname -s)" == "Darwin" ]]; then + # The GUI app's CLI runs as the user; no root needed. + if "$ts_bin" up --timeout=300s; then up_ok="1"; fi + else + if [[ -e /dev/tty ]]; then + if run_as_root "$ts_bin" up --timeout=300s < /dev/tty; then up_ok="1"; fi + else + if run_as_root "$ts_bin" up --timeout=300s; then up_ok="1"; fi + fi + fi + if [[ "$up_ok" != "1" ]]; then + if [[ "$(uname -s)" == "Darwin" ]]; then + info "If the CLI cannot log in, open the Tailscale app, log in there, then run:" + info " bash $INSTALL_DIR/install.sh tailscale" + fi + return 1 + fi + [[ "$(ts_status_field 's.BackendState')" == "Running" ]] +} + +# Linux: `tailscale serve` needs root or operator rights. Grant operator once +# (with the user's consent via sudo) so serve config never needs sudo again; +# fall back to sudo-per-command when the grant fails. +ensure_tailscale_operator() { + if [[ "$(uname -s)" == "Darwin" ]] || [[ $EUID -eq 0 ]]; then + return 0 + fi + if ts_cmd serve status &>/dev/null; then + return 0 + fi + if ! command -v sudo &>/dev/null; then + warn "No sudo available; tailscale serve configuration may fail without root." + TS_NEED_ROOT="1" + return 0 + fi + info "Granting your user Tailscale 'operator' rights (one-time sudo; lets serve run without root)..." + local ts_bin + ts_bin=$(get_tailscale_path) || return 0 + if run_as_root "$ts_bin" set --operator="$USER" 2>/dev/null && ts_cmd serve status &>/dev/null; then + success "Operator rights granted" + return 0 + fi + warn "Could not grant operator rights; serve commands will use sudo." + TS_NEED_ROOT="1" + return 0 +} + +# HTTPS certificates are a per-tailnet admin toggle. Serve without them cannot +# terminate TLS, and a plain-HTTP fallback would silently break the "real +# HTTPS" promise (PWA install, web push), so guide the user through enabling +# them instead of degrading. +ensure_tailnet_https() { + while true; do + local magic cert + magic=$(ts_status_field 's.CurrentTailnet && s.CurrentTailnet.MagicDNSEnabled ? "1" : ""') + cert=$(ts_status_field 'Array.isArray(s.CertDomains) && s.CertDomains.length > 0 ? "1" : ""') + if [[ "$magic" == "1" && "$cert" == "1" ]]; then + return 0 + fi + warn "Your tailnet has not enabled HTTPS certificates yet (a one-time admin toggle)." + echo -e " Open ${CYAN}https://login.tailscale.com/admin/dns${NC} and enable:" >&2 + if [[ "$magic" == "1" ]]; then + echo -e " ${CYAN}1.${NC} MagicDNS ${GREEN}(already on)${NC}" >&2 + else + echo -e " ${CYAN}1.${NC} MagicDNS" >&2 + fi + if [[ "$cert" == "1" ]]; then + echo -e " ${CYAN}2.${NC} HTTPS Certificates ${GREEN}(already on)${NC}" >&2 + else + echo -e " ${CYAN}2.${NC} HTTPS Certificates" >&2 + fi + if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then + return 1 + fi + if ! prompt_yes_no "Re-check now? (answering no skips Tailscale setup)" "y"; then + return 1 + fi + done +} + +setup_tailscale_serve() { + local port="${CODEMAN_PORT:-3000}" + local dns url existing + dns=$(ts_status_field 's.Self && s.Self.DNSName') + if [[ -z "$dns" ]]; then + warn "Could not determine this machine's tailnet DNS name." + return 1 + fi + url="https://${dns%.}" + + existing=$(ts_serve_443_target_port) + if [[ "$existing" == "$port" ]]; then + TAILSCALE_SERVE_URL="$url" + success "Tailscale serve already forwards $url to port $port (kept as-is)" + return 0 + fi + if [[ -n "$existing" ]]; then + warn "tailscale serve already forwards $url (port 443) to local port $existing." + if ! prompt_yes_no "Replace that mapping with Codeman (port $port)?" "n"; then + info "Keeping the existing mapping." + return 1 + fi + fi + + info "Configuring: tailscale serve --bg $port" + local serve_out + if serve_out=$(ts_cmd_serve serve --bg "$port" 2>&1); then + TAILSCALE_SERVE_URL="$url" + success "Tailscale HTTPS enabled: $url" + echo -e " ${DIM}(persists across reboots; inspect with: tailscale serve status)${NC}" + return 0 + fi + warn "tailscale serve failed:" + printf '%s\n' "$serve_out" | sed 's/^/ /' >&2 + return 1 +} + +# Curl the ts.net URL until it answers. 200 = reachable; 401 = reachable behind +# the dashboard password. The first request can be slow while tailscaled +# obtains the Let's Encrypt certificate. +verify_tailscale_access() { + if [[ -z "$TAILSCALE_SERVE_URL" ]]; then + return 0 + fi + if ! command -v curl &>/dev/null; then + info "curl not available; open $TAILSCALE_SERVE_URL to verify." + return 0 + fi + info "Verifying $TAILSCALE_SERVE_URL (first load can take ~30s while the HTTPS certificate is issued)..." + local i http_code + for ((i = 1; i <= 10; i++)); do + http_code=$(curl -skm 10 -o /dev/null -w '%{http_code}' "$TAILSCALE_SERVE_URL/api/status" 2>/dev/null) || http_code="" + if [[ "$http_code" == "200" || "$http_code" == "401" ]]; then + success "Reachable: $TAILSCALE_SERVE_URL" + return 0 + fi + sleep 3 + done + warn "Could not reach $TAILSCALE_SERVE_URL/api/status yet." + warn "It may need another minute (certificate issuance). Inspect: tailscale serve status" + warn "If Codeman itself runs with --https, the serve target must be:" + warn " tailscale serve --bg https+insecure://localhost:${CODEMAN_PORT:-3000}" + return 1 +} + +# Orchestrator: walk every state (not installed -> logged out -> operator -> +# tailnet HTTPS -> serve) and end with TAILSCALE_SERVE_URL set, or fall back +# gracefully (the caller keeps the loopback bind either way). +setup_tailscale_access() { + TAILSCALE_SERVE_URL="" + if ! check_tailscale; then + if ! offer_install_tailscale; then + tailscale_retrofit_hint "Tailscale is not installed" + return 1 + fi + fi + if ! command -v node &>/dev/null; then + tailscale_retrofit_hint "node is not on PATH yet" + return 1 + fi + if ! ensure_tailscale_login; then + tailscale_retrofit_hint "Tailscale is not connected" + return 1 + fi + ensure_tailscale_operator + if ! ensure_tailnet_https; then + tailscale_retrofit_hint "HTTPS certificates are not enabled for your tailnet" + return 1 + fi + if ! setup_tailscale_serve; then + tailscale_retrofit_hint "tailscale serve could not be configured" + return 1 + fi + return 0 +} + +# `install.sh tailscale`: retrofit Tailscale access onto an existing install +# (also the target of every "set it up later" hint above). +setup_tailscale_subcommand() { + print_banner + if ! command -v node &>/dev/null; then + die "node is required. Install Codeman first (run the installer without arguments)." + fi + + read_existing_binding + if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then + warn "Your service binds $EXISTING_HOST (network-wide). Tailscale serve will work, but the" + warn "dashboard stays reachable on your LAN too. Re-run the installer and choose Tailscale" + warn "to switch to the tighter loopback-only bind." + echo "" + fi + + if ! setup_tailscale_access; then + exit 1 + fi + + # Verify end-to-end only when Codeman is actually answering locally. + local port="${CODEMAN_PORT:-3000}" server_up="0" + if command -v curl &>/dev/null; then + if curl -skm 5 -o /dev/null "http://127.0.0.1:$port/api/status" 2>/dev/null || + curl -skm 5 -o /dev/null "https://127.0.0.1:$port/api/status" 2>/dev/null; then + server_up="1" + fi + fi + if [[ "$server_up" == "1" ]]; then + verify_tailscale_access || true + else + info "Codeman does not appear to be running on port $port right now." + info "Once it is, open: $TAILSCALE_SERVE_URL" + fi + + BIND_HOST="${EXISTING_HOST:-127.0.0.1}" + BIND_PASSWORD="$EXISTING_PASSWORD" + print_security_notice +} + # ============================================================================ # Service Setup (Linux systemd / macOS launchd) # ============================================================================ @@ -1773,10 +2279,19 @@ main() { echo "" if [[ "$service_ok" == "true" ]]; then + # With Tailscale configured, prove the URL actually answers now + # that the server is up (never claim success blindly). + if [[ -n "$TAILSCALE_SERVE_URL" ]]; then + verify_tailscale_access || true + echo "" + fi echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}" echo "" echo -e " ${CYAN}# Open in browser${NC}" - if [[ "$BIND_HOST" == "0.0.0.0" ]]; then + if [[ -n "$TAILSCALE_SERVE_URL" ]]; then + echo -e " $TAILSCALE_SERVE_URL ${DIM}(any device on your tailnet, HTTPS)${NC}" + echo -e " http://localhost:3000 ${DIM}(this machine)${NC}" + elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}" echo -e " http://localhost:3000 ${DIM}(this machine)${NC}" else @@ -1822,10 +2337,21 @@ main() { echo "" echo -e " ${CYAN}# Open in browser${NC}" echo -e " http://localhost:3000" + if [[ -n "$TAILSCALE_SERVE_URL" ]]; then + echo -e " $TAILSCALE_SERVE_URL ${DIM}(any device on your tailnet, once running)${NC}" + fi fi echo "" fi + if [[ -n "$TAILSCALE_SERVE_URL" ]]; then + echo -e " ${BOLD}Remote Access (Tailscale):${NC}" + echo "" + echo -e " $TAILSCALE_SERVE_URL ${DIM}(HTTPS, any device on your tailnet)${NC}" + echo -e " ${CYAN}tailscale serve status${NC} # Inspect the mapping" + echo "" + fi + if check_cloudflared; then echo -e " ${BOLD}Remote Access (Cloudflare Tunnel):${NC}" echo "" @@ -1982,6 +2508,20 @@ uninstall() { success "Removed LaunchDaemon" fi + # Remove OUR tailscale serve mapping (443 -> Codeman's port) only. Other + # serve config stays untouched, and never `tailscale serve reset`. + local ts_url="" + ts_url=$(detect_tailscale_serve_url 2>/dev/null) || ts_url="" + if [[ -n "$ts_url" ]]; then + if prompt_yes_no "Remove the Tailscale serve mapping for Codeman ($ts_url)?" "y"; then + if ts_cmd_serve serve --https=443 off 2>/dev/null; then + success "Removed tailscale serve mapping" + else + warn "Could not remove it automatically. Run: tailscale serve --https=443 off" + fi + fi + fi + # Remove symlinks local symlink_dir="$HOME/.local/bin" if [[ -L "$symlink_dir/codeman" ]]; then @@ -2030,6 +2570,7 @@ uninstall() { case "${1:-}" in update) update ;; uninstall) uninstall ;; + tailscale) setup_tailscale_subcommand ;; *) # Only a COMPLETED install re-runs as a quiet update. A partial one # (clone succeeded but build/menu never finished) lacks the marker and diff --git a/package-lock.json b/package-lock.json index e457d793..5357ac81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.9.7", + "version": "1.9.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.9.7", + "version": "1.9.8", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -12333,7 +12333,7 @@ } }, "packages/xterm-zerolag-input": { - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "devDependencies": { "jsdom": "^24.1.3", diff --git a/package.json b/package.json index 45c9f8fb..e315fd12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.9.7", + "version": "1.9.8", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", @@ -22,6 +22,7 @@ "test:coverage": "vitest run --config config/vitest.config.ts --coverage", "test:ci": "vitest run --config config/vitest.ci.config.ts", "check:frontend-syntax": "node scripts/check-frontend-syntax.mjs", + "fix:node-pty": "node scripts/fix-node-pty.mjs", "typecheck": "tsc --noEmit", "lint": "eslint --config config/eslint.config.js 'src/**/*.ts'", "lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix", @@ -156,6 +157,7 @@ "files": [ "dist", "scripts/postinstall.js", + "scripts/fix-node-pty.mjs", "LICENSE", "README.md" ] diff --git a/packages/xterm-zerolag-input/CHANGELOG.md b/packages/xterm-zerolag-input/CHANGELOG.md index 892a3907..c7e4b923 100644 --- a/packages/xterm-zerolag-input/CHANGELOG.md +++ b/packages/xterm-zerolag-input/CHANGELOG.md @@ -1,5 +1,34 @@ # xterm-zerolag-input +## 0.1.8 + +### Patch Changes + +- **Fixed: sessions failed to start on macOS with `Error: posix_spawnp failed.`** (issues #6 and #204) + + `node-pty@1.1.0` publishes its macOS prebuilt helper as `prebuilds/darwin-/spawn-helper` with mode 0644, i.e. no execute bit. macOS launches every PTY through that helper, so a stock install failed on every session start. The bug is macOS-only: `spawn-helper` is a mac-only gyp target and node-pty ships no Linux prebuild, so Linux always compiles a correctly-permissioned helper from source. + + The previous fix chmodded only `build/Release/spawn-helper`, which on macOS does not exist (the prebuild is used, so node-gyp never runs), and it derived that path from `require.resolve('node-pty')`, landing on `/lib/build/Release/...`. It was a no-op on every platform. + - New `scripts/fix-node-pty.mjs` (also `npm run fix:node-pty`) chmods every `spawn-helper` it finds, in `build/Release`, `build/Debug` and each `prebuilds/*/`, then verifies the result by actually opening a PTY. A `require()` alone passes on a broken install, because the helper is only touched at spawn time. + - `postinstall` no longer force-rebuilds node-pty from source on Node 22+. That step needed Xcode command line tools, cost 30-120s on every install, and deleted the `prebuilds/` tree before compiling, so a Mac without a compiler was left with no working binary at all. A rebuild now happens only when the chmod plus spawn probe still fails, and the prebuilds tree is backed up and restored around it. + - New `spawnPtyWithHelperRepair()` (`src/utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts`, so an install that is already broken repairs itself on the first failed spawn and retries in-process instead of showing a dead session. Unrelated spawn errors are rethrown untouched; a second failure carries the `npm run fix:node-pty` hint. + - `scripts/fix-node-pty.mjs` is now in the published `files` list, so global npm installs get the repair too. + - Direct-PTY Claude spawns use the resolved absolute binary path (new `getClaudeBinaryPath()`) instead of the bare name `claude`, so a CLI installed outside the server's PATH still launches. + + Verified end to end on macOS 26.4 arm64: a stock `npm i` reproduces `posix_spawnp failed.`, and after the fix the same install spawns a PTY successfully with the prebuilds preserved. + + **Added: phone home screen (session overview)** + + Under 430px the "C" logo now opens a session overview (current sessions, past sessions, spaces) instead of the welcome overlay: on a small screen "which session needs me" beats "how do I start one". Rows resume a session in place, and "New session here" goes through the normal quick-start path so remote and Docker cases keep their routing. Per-device setting `mobileOverviewEnabled` (phones only, default ON) in App Settings. Tablet and desktop are unchanged. + + **Added: guided Tailscale setup in `install.sh`** + + The network-access prompt is now 3-way: Tailscale, LAN, or local-only. The Tailscale path binds loopback and walks through installing Tailscale, logging in, the operator grant, the tailnet HTTPS-certificates toggle, and `tailscale serve --bg `, then verifies the result end to end with curl. That gives HTTPS on a real certificate with no app password and no `0.0.0.0` bind, which is also what PWA install and web push need. `install.sh tailscale` retrofits it onto an existing install, and `CODEMAN_TAILSCALE=1` presets the choice. Serve state is detected from `tailscale serve status --json`; the installer never runs `tailscale serve reset` and never touches serve mappings other than 443 to Codeman's port. README and `docs/security-architecture.md` updated to match. + + **Docs**: replaced a real tailnet hostname with placeholders in `docs/web-tabs-fixes-plan.md`. + + **xterm-zerolag-input**: npm description and keywords only, no code change. + ## 0.1.7 ### Patch Changes diff --git a/packages/xterm-zerolag-input/package.json b/packages/xterm-zerolag-input/package.json index 6b86fd10..48538c0d 100644 --- a/packages/xterm-zerolag-input/package.json +++ b/packages/xterm-zerolag-input/package.json @@ -1,7 +1,7 @@ { "name": "xterm-zerolag-input", - "version": "0.1.7", - "description": "Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections", + "version": "0.1.8", + "description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", @@ -26,8 +26,16 @@ "xterm", "xterm.js", "terminal", + "web-terminal", "local-echo", + "local echo", + "mosh", "input-latency", + "latency", + "zero-lag", + "keystroke", + "ssh", + "remote-terminal", "overlay", "addon" ], diff --git a/scripts/fix-node-pty.mjs b/scripts/fix-node-pty.mjs new file mode 100644 index 00000000..dabccd76 --- /dev/null +++ b/scripts/fix-node-pty.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env node +/** + * @fileoverview Repairs node-pty's macOS `spawn-helper` and verifies that a PTY + * can really be spawned. Called by `scripts/postinstall.js` on every install and + * exposed as `npm run fix:node-pty` for repairing an install after the fact. + * + * Why this exists (issues #6 and #204): + * + * node-pty@1.1.0 publishes its macOS prebuilt helper as + * `prebuilds/darwin-/spawn-helper` with mode 0644, i.e. no execute bit. + * On macOS node-pty launches every PTY through that helper with posix_spawnp, + * which then fails EACCES and surfaces as `Error: posix_spawnp failed.` on every + * session start. + * + * It is macOS-exclusive twice over: `spawn-helper` is an `OS=="mac"` gyp target, + * and pty.cc only spawns it under `#if defined(__APPLE__)`. node-pty ships + * prebuilds for darwin and win32 only, so Linux always compiles from source + * (which produces an executable helper) and never sees the bug. + * + * The repair is a chmod, NOT a rebuild: the prebuilt binary itself is fine, and + * requiring a from-source rebuild would make every macOS install depend on Xcode + * command line tools. A rebuild is attempted only when a chmod plus a real spawn + * probe still can't get a working PTY, and the prebuilds tree is backed up first + * so a failed rebuild can never leave the install worse than it started. + */ + +import { chmodSync, cpSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); + +/** Errors that mean "the native module or its helper is unusable", i.e. worth a rebuild. */ +const NATIVE_FAILURE_PATTERN = /posix_spawnp|spawn-helper|Failed to load native module|Cannot find module/i; + +/** + * Locates the installed node-pty package directory. + * + * @returns {string|null} Absolute path to the package root, or null if not installed. + */ +export function findNodePtyDir() { + // package.json first: node-pty declares no "exports" map, so the subpath resolves, + // and it lands on the package root directly. require.resolve('node-pty') would give + // /lib/index.js, which is one directory deeper than callers expect. + try { + return dirname(require.resolve('node-pty/package.json')); + } catch { + /* fall through */ + } + try { + return join(dirname(require.resolve('node-pty')), '..'); + } catch { + return null; + } +} + +/** + * Lists every `spawn-helper` shipped in a node-pty install. + * + * node-pty's own loader (lib/utils.js) checks `build/Release`, `build/Debug` and + * then `prebuilds/-`, and takes the helper from whichever + * directory the native module loaded out of, so all of them must be executable, + * not just the one this machine happens to use today. + * + * @param {string} ptyDir Absolute path to the node-pty package root. + * @returns {string[]} Absolute paths of the helpers that exist on disk. + */ +export function listSpawnHelpers(ptyDir) { + const dirs = [join(ptyDir, 'build', 'Release'), join(ptyDir, 'build', 'Debug')]; + + const prebuilds = join(ptyDir, 'prebuilds'); + if (existsSync(prebuilds)) { + try { + for (const entry of readdirSync(prebuilds, { withFileTypes: true })) { + if (entry.isDirectory()) dirs.push(join(prebuilds, entry.name)); + } + } catch { + /* unreadable prebuilds dir: nothing to repair there */ + } + } + + return dirs.map((d) => join(d, 'spawn-helper')).filter((p) => existsSync(p)); +} + +/** + * Adds the execute bit to every `spawn-helper` that is missing it. + * + * @param {string} ptyDir Absolute path to the node-pty package root. + * @returns {{ repaired: string[], failed: Array<{ path: string, error: string }> }} + */ +export function repairSpawnHelpers(ptyDir) { + const repaired = []; + const failed = []; + + for (const helper of listSpawnHelpers(ptyDir)) { + try { + const mode = statSync(helper).mode & 0o777; + if ((mode & 0o111) === 0o111) continue; // already executable by all + chmodSync(helper, mode | 0o755); + repaired.push(helper); + } catch (err) { + failed.push({ path: helper, error: err instanceof Error ? err.message : String(err) }); + } + } + + return { repaired, failed }; +} + +/** + * Proves node-pty works by actually opening a PTY, which is the only check that + * exercises the spawn-helper path that breaks. A `require` alone would pass on a + * broken install, because the helper is only touched at spawn time. + * + * @param {string} ptyDir Absolute path to the node-pty package root. + * @returns {{ ok: boolean, error?: string, nativeFailure?: boolean }} + */ +export function verifyPtySpawn(ptyDir) { + let child; + try { + const pty = require(ptyDir); // directory require → node-pty's "main" (lib/index.js) + const file = process.platform === 'win32' ? process.env.COMSPEC || 'cmd.exe' : '/bin/echo'; + const args = process.platform === 'win32' ? ['/c', 'exit'] : ['codeman-node-pty-check']; + child = pty.spawn(file, args, { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: tmpdir(), + env: process.env, + }); + return { ok: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: message, nativeFailure: NATIVE_FAILURE_PATTERN.test(message) }; + } finally { + try { + child?.kill(); + } catch { + /* the probe child exits on its own anyway */ + } + } +} + +/** + * Rebuilds node-pty from source, preserving the prebuilds tree across a failure. + * + * node-pty's install script deletes `prebuilds/` as soon as + * `npm_config_build_from_source` is set and only then shells out to node-gyp, so + * a machine without a compiler toolchain would otherwise be left with neither a + * prebuilt nor a compiled binary. + * + * @param {string} ptyDir Absolute path to the node-pty package root. + * @param {string} cwd Directory to run npm from (the package root that owns node_modules). + * @returns {{ ok: boolean, error?: string }} + */ +function rebuildFromSource(ptyDir, cwd) { + const prebuilds = join(ptyDir, 'prebuilds'); + const backup = join(ptyDir, '.prebuilds-codeman-backup'); + + let backedUp = false; + if (existsSync(prebuilds)) { + try { + rmSync(backup, { recursive: true, force: true }); + cpSync(prebuilds, backup, { recursive: true }); + backedUp = true; + } catch { + /* best effort: proceed without a safety net rather than skip the repair */ + } + } + + try { + execSync('npm rebuild node-pty --build-from-source', { cwd, stdio: 'pipe', timeout: 300000 }); + return { ok: true }; + } catch (err) { + if (backedUp && !existsSync(prebuilds)) { + try { + cpSync(backup, prebuilds, { recursive: true }); + } catch { + /* nothing further we can do */ + } + } + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } finally { + rmSync(backup, { recursive: true, force: true }); + } +} + +/** + * Full repair flow: chmod, verify, and only rebuild if a working PTY still can't + * be opened. + * + * @param {object} [options] + * @param {(line: string) => void} [options.log] Progress sink (default: silent). + * @param {(line: string) => void} [options.warn] Warning sink (default: same as log). + * @param {boolean} [options.allowRebuild] Permit a from-source rebuild (default: true). + * @returns {Promise<{ ok: boolean, repaired: string[], rebuilt: boolean, reason?: string }>} + */ +export async function fixNodePty(options = {}) { + const log = options.log ?? (() => {}); + const warn = options.warn ?? log; + const allowRebuild = options.allowRebuild ?? true; + + const ptyDir = findNodePtyDir(); + if (!ptyDir) { + return { ok: false, repaired: [], rebuilt: false, reason: 'node-pty is not installed' }; + } + + const { repaired, failed } = repairSpawnHelpers(ptyDir); + for (const f of failed) warn(`could not chmod ${f.path}: ${f.error}`); + if (repaired.length > 0) { + log(`made node-pty spawn-helper executable (${repaired.length} file${repaired.length === 1 ? '' : 's'})`); + } + + const first = verifyPtySpawn(ptyDir); + if (first.ok) return { ok: true, repaired, rebuilt: false }; + + if (!allowRebuild || !first.nativeFailure) { + return { ok: false, repaired, rebuilt: false, reason: first.error }; + } + + warn(`node-pty could not open a PTY (${first.error}), rebuilding from source...`); + const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + const rebuild = rebuildFromSource(ptyDir, projectRoot); + if (!rebuild.ok) { + return { ok: false, repaired, rebuilt: false, reason: `rebuild failed: ${rebuild.error}` }; + } + + const after = repairSpawnHelpers(ptyDir); + repaired.push(...after.repaired); + + const second = verifyPtySpawn(ptyDir); + return second.ok + ? { ok: true, repaired, rebuilt: true } + : { ok: false, repaired, rebuilt: true, reason: second.error }; +} + +// --------------------------------------------------------------------------- +// CLI: node scripts/fix-node-pty.mjs [--quiet] +// --------------------------------------------------------------------------- + +const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; + +if (isDirectRun) { + const quiet = process.argv.includes('--quiet'); + const say = (line) => { + if (!quiet) console.log(line); + }; + + const result = await fixNodePty({ log: say, warn: (line) => console.warn(line) }); + + if (result.ok) { + say(result.repaired.length > 0 || result.rebuilt ? 'node-pty repaired, PTY spawning works' : 'node-pty is healthy'); + process.exit(0); + } + + console.error(`node-pty is not usable: ${result.reason}`); + console.error('Try: cd node_modules/node-pty && npx node-gyp rebuild'); + process.exit(1); +} diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 8b56b632..7654705f 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -6,7 +6,7 @@ */ import { execSync, spawn } from 'child_process'; -import { chmodSync, existsSync } from 'fs'; +import { existsSync } from 'fs'; import { homedir, platform } from 'os'; import { join } from 'path'; import { createRequire } from 'module'; @@ -148,35 +148,32 @@ if (majorVersion < MIN_NODE_VERSION) { } // ---------------------------------------------------------------------------- -// 1b. Fix node-pty spawn-helper permissions (macOS posix_spawnp fix) +// 1b. Repair + verify node-pty (macOS posix_spawnp fix, issues #6 and #204) +// +// node-pty ships its macOS spawn-helper without the execute bit, which breaks +// every session start on macOS. fixNodePty() chmods it, then proves a PTY can +// actually be opened, and only falls back to a from-source rebuild if that +// still fails. See scripts/fix-node-pty.mjs for the full story. // ---------------------------------------------------------------------------- try { - const require = createRequire(import.meta.url); - const ptyPath = join(require.resolve('node-pty'), '..'); - const spawnHelper = join(ptyPath, 'build', 'Release', 'spawn-helper'); - if (existsSync(spawnHelper)) { - chmodSync(spawnHelper, 0o755); - console.log(colors.green('✓ node-pty spawn-helper permissions fixed')); - } -} catch { - // Non-critical — only affects macOS with prebuilt binaries -} + const { fixNodePty } = await import('./fix-node-pty.mjs'); + const result = await fixNodePty({ + log: (line) => console.log(colors.dim(` ${line}`)), + warn: (line) => console.log(colors.yellow(`⚠ ${line}`)), + }); -// ---------------------------------------------------------------------------- -// 1c. Rebuild node-pty from source for Node.js 22+ compatibility -// ---------------------------------------------------------------------------- - -if (majorVersion >= 22) { - try { - console.log(colors.dim(' Rebuilding node-pty from source for Node.js 22+...')); - execSync('npm rebuild node-pty --build-from-source', { stdio: 'pipe', timeout: 120000 }); - console.log(colors.green('✓ node-pty rebuilt from source')); - } catch { + if (result.ok) { + console.log(colors.green('✓ node-pty verified') + colors.dim(' (PTY spawn works)')); + } else { hasWarnings = true; - console.log(colors.yellow('⚠ Failed to rebuild node-pty from source')); - console.log(colors.dim(' You may need to run: npm rebuild node-pty --build-from-source')); + console.log(colors.yellow(`⚠ node-pty is not usable: ${result.reason}`)); + console.log(colors.dim(' Sessions will fail to start. Try: ') + colors.cyan('npm run fix:node-pty')); } +} catch (err) { + hasWarnings = true; + console.log(colors.yellow(`⚠ Could not verify node-pty: ${err.message}`)); + console.log(colors.dim(' If sessions fail to start, run: ') + colors.cyan('npm run fix:node-pty')); } // ---------------------------------------------------------------------------- diff --git a/src/session.ts b/src/session.ts index 5cf4e1b8..9a5ca134 100644 --- a/src/session.ts +++ b/src/session.ts @@ -66,6 +66,8 @@ import { MAX_SESSION_TOKENS, execPattern, getClaudeCliVersion, + getClaudeBinaryPath, + spawnPtyWithHelperRepair, } from './utils/index.js'; import { MAX_TERMINAL_BUFFER_SIZE, @@ -1284,15 +1286,17 @@ export class Session extends EventEmitter { const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand(); const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName); try { - this.ptyProcess = pty.spawn(attachCommand, attachArgs, { - name: 'xterm-256color', - cols: ptyCols, - rows: ptyRows, - cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), - // COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports() - // in tmux-manager.ts so the attach client and the tmux session agree. - env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'), - }); + this.ptyProcess = spawnPtyWithHelperRepair(() => + pty.spawn(attachCommand, attachArgs, { + name: 'xterm-256color', + cols: ptyCols, + rows: ptyRows, + cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), + // COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports() + // in tmux-manager.ts so the attach client and the tmux session agree. + env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'), + }) + ); } catch (spawnErr) { console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr); this.emit('error', `Failed to attach to mux session: ${spawnErr}`); @@ -1619,14 +1623,16 @@ export class Session extends EventEmitter { // Pass --session-id to use the SAME ID as the Codeman session // This ensures subagents can be directly matched to the correct tab const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort); - this.ptyProcess = pty.spawn('claude', args, { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. - env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, - }); + this.ptyProcess = spawnPtyWithHelperRepair(() => + pty.spawn(getClaudeBinaryPath(), args, { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. + env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, + }) + ); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY:', spawnErr); this._status = 'stopped'; @@ -1949,13 +1955,15 @@ export class Session extends EventEmitter { // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { try { - this.ptyProcess = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: buildShellEnv(this.id), - }); + this.ptyProcess = spawnPtyWithHelperRepair(() => + pty.spawn(shell, [], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: buildShellEnv(this.id), + }) + ); } catch (spawnErr) { console.error('[Session] Failed to spawn shell PTY:', spawnErr); this._status = 'stopped'; @@ -2055,14 +2063,16 @@ export class Session extends EventEmitter { const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools); try { - this.ptyProcess = pty.spawn('claude', args, { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. - env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, - }); + this.ptyProcess = spawnPtyWithHelperRepair(() => + pty.spawn(getClaudeBinaryPath(), args, { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. + env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, + }) + ); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr); this.emit( diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 398680d2..b9292201 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -59,6 +59,21 @@ export function findClaudeDir(): string | null { return null; } +/** + * Returns an absolute path to the `claude` binary, falling back to the bare + * name `'claude'` when it cannot be located (so PATH resolution still gets a + * chance). + * + * Preferred over passing `'claude'` to `pty.spawn()`: a PTY child resolves the + * command against the environment it is handed, and an install that lives in + * `~/.local/bin` or `~/.claude/local` is frequently absent from the PATH the + * server process inherited (issue #6). + */ +export function getClaudeBinaryPath(): string { + const dir = findClaudeDir(); + return dir ? join(dir, 'claude') : 'claude'; +} + /** Cached augmented PATH string */ let _augmentedPath: string | null = null; diff --git a/src/utils/index.ts b/src/utils/index.ts index 90c9f5e8..160ca712 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -26,7 +26,8 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js'; export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; export { assertNever } from './type-safety.js'; export { wrapWithNice } from './nice-wrapper.js'; -export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js'; +export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js'; +export { spawnPtyWithHelperRepair } from './node-pty-repair.js'; export { resolveOpenCodeDir } from './opencode-cli-resolver.js'; export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js'; export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js'; diff --git a/src/utils/node-pty-repair.ts b/src/utils/node-pty-repair.ts new file mode 100644 index 00000000..753782fb --- /dev/null +++ b/src/utils/node-pty-repair.ts @@ -0,0 +1,153 @@ +/** + * @fileoverview Runtime self-heal for node-pty's macOS `spawn-helper`. + * + * node-pty@1.1.0 ships its macOS prebuilt helper as + * `prebuilds/darwin-/spawn-helper` with mode 0644 (no execute bit). On + * macOS every PTY is launched through that helper via posix_spawnp, so a + * non-executable helper turns every session start into + * `Error: posix_spawnp failed.` (issues #6 and #204). The bug is macOS-only: + * `spawn-helper` is an `OS=="mac"` gyp target and pty.cc only spawns it under + * `#if defined(__APPLE__)`, and node-pty ships no Linux prebuild, so Linux always + * compiles a correctly-permissioned helper from source. + * + * `scripts/fix-node-pty.mjs` fixes this at install time. This module is the + * safety net for installs that are already broken: the first PTY spawn that + * fails this way is repaired and retried in-process, so the user never sees a + * dead session. If the retry still fails, the thrown error carries the manual + * repair command instead of a bare "posix_spawnp failed". + * + * @module utils/node-pty-repair + */ + +import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +const require = createRequire(import.meta.url); + +/** The one-line fix appended to errors we could not repair automatically. */ +export const SPAWN_HELPER_FIX_HINT = + 'node-pty cannot execute its spawn-helper. Repair it with: npm run fix:node-pty ' + + '(or: chmod +x node_modules/node-pty/prebuilds/*/spawn-helper)'; + +/** Set once a repair has been attempted, so a genuinely broken install cannot chmod-storm. */ +let repairAttempted = false; + +/** + * True when an error is node-pty failing to launch its spawn-helper. + * + * The native throw site is `throw Napi::Error::New(napiEnv, "posix_spawnp failed.")` + * in pty.cc, reached only on Apple platforms. + */ +export function isSpawnHelperFailure(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? ''); + return /posix_spawnp|spawn-helper/i.test(message); +} + +/** Locates the installed node-pty package root, or null when it can't be resolved. */ +export function findNodePtyDir(): string | null { + // node-pty declares no "exports" map, so the package.json subpath resolves and + // lands on the package root. require.resolve('node-pty') would return + // /lib/index.js, one level deeper than callers need. + try { + return dirname(require.resolve('node-pty/package.json')); + } catch { + /* fall through */ + } + try { + return join(dirname(require.resolve('node-pty')), '..'); + } catch { + return null; + } +} + +/** + * Lists every `spawn-helper` present in a node-pty install. + * + * node-pty's loader checks `build/Release`, `build/Debug`, then + * `prebuilds/-`, and takes the helper from whichever directory + * the native module loaded out of, so every copy has to be executable, not just + * the one this machine happens to use. + */ +export function listSpawnHelpers(ptyDir: string): string[] { + const dirs = [join(ptyDir, 'build', 'Release'), join(ptyDir, 'build', 'Debug')]; + + const prebuilds = join(ptyDir, 'prebuilds'); + if (existsSync(prebuilds)) { + try { + for (const entry of readdirSync(prebuilds, { withFileTypes: true })) { + if (entry.isDirectory()) dirs.push(join(prebuilds, entry.name)); + } + } catch { + /* unreadable prebuilds dir: nothing to repair there */ + } + } + + return dirs.map((d) => join(d, 'spawn-helper')).filter((p) => existsSync(p)); +} + +/** + * Adds the execute bit to every `spawn-helper` missing it. + * + * @param ptyDir - node-pty package root; resolved automatically when omitted. + * @returns Paths actually changed (empty when nothing needed repair, or node-pty + * is missing, or the files are not writable). + */ +export function repairSpawnHelperPermissions(ptyDir?: string): string[] { + const dir = ptyDir ?? findNodePtyDir(); + if (!dir) return []; + + const repaired: string[] = []; + for (const helper of listSpawnHelpers(dir)) { + try { + const mode = statSync(helper).mode & 0o777; + if ((mode & 0o111) === 0o111) continue; + chmodSync(helper, mode | 0o755); + repaired.push(helper); + } catch { + // Read-only install (or not ours to chmod): fall through to the hint. + } + } + return repaired; +} + +/** Wraps an error so the message carries the actionable repair command. */ +function withFixHint(err: unknown): Error { + const message = err instanceof Error ? err.message : String(err); + return new Error(`${message}. ${SPAWN_HELPER_FIX_HINT}`, { cause: err }); +} + +/** + * Runs a `pty.spawn()` call, repairing a non-executable spawn-helper and + * retrying once if that is why it failed. + * + * Any unrelated spawn error is rethrown untouched, so this stays invisible on + * every platform but a broken macOS install. + * + * @param spawn - The `pty.spawn(...)` call to run. + * @param ptyDir - node-pty package root; resolved automatically when omitted. + */ +export function spawnPtyWithHelperRepair(spawn: () => T, ptyDir?: string): T { + try { + return spawn(); + } catch (err) { + if (!isSpawnHelperFailure(err)) throw err; + if (repairAttempted) throw withFixHint(err); + + repairAttempted = true; + const repaired = repairSpawnHelperPermissions(ptyDir); + if (repaired.length === 0) throw withFixHint(err); + + console.warn(`[node-pty] spawn-helper was not executable, repaired ${repaired.join(', ')} and retrying`); + try { + return spawn(); + } catch (retryErr) { + throw withFixHint(retryErr); + } + } +} + +/** Test seam: forget that a repair was already attempted in this process. */ +export function resetSpawnHelperRepairState(): void { + repairAttempted = false; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 0b904577..0498bb22 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3456,6 +3456,10 @@ class CodemanApp { // After the wrap measurement: the `unroll` style starts tabs at max-width 0, // so measuring mid-animation would decide the wrap on collapsed widths. this._applyTabEntrances?.(); + // Phone overview rides on this one call: every state change it cares about + // (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks) + // already funnels through here. No-ops unless that surface is showing. + this._refreshMobileOverviewIfVisible?.(); } // Auto-wrap desktop session tabs to a second row when they overflow one row, diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 8fd93d72..ac06606e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -350,6 +350,26 @@ 'Show Shortcuts': '显示快捷键', 'Full shortcut reference': '完整快捷键参考', + // Mobile overview (phone home screen) + 'Needs you': '需要你', + 'Current sessions': '当前会话', + 'Past sessions': '历史会话', + 'Show all past sessions': '显示全部历史会话', + 'Show fewer': '收起', + 'Choose what to run': '选择运行方式', + 'Web / URL': '网页 / 链接', + 'Add URL…': '添加链接…', + 'Nothing running. Hit Run to start something.': '当前没有运行中的会话。点击“运行”开始。', + 'No past conversations yet': '尚无历史对话', + 'Loading…': '加载中…', + // Status pills are deliberately NOT listed: they are single generic words + // ("idle", "done", "error") that also appear as state strings elsewhere, so + // they carry data-i18n-skip in the DOM instead of a translation entry here. + 'Overview Home Screen': '概览主页', + 'On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen': + '在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页', + Phone: '手机', + // Session/case dialogs 'Session Options': '会话选项', 'Session Name': '会话名称', diff --git a/src/web/public/index.html b/src/web/public/index.html index aa04f78d..adb3d8f4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -379,6 +379,12 @@ + + + @@ -1442,6 +1448,16 @@ + +
Phone
+
+ Overview Home Screen + +
+
Tab Bar
@@ -2644,6 +2660,7 @@ + diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js new file mode 100644 index 00000000..26927e6a --- /dev/null +++ b/src/web/public/mobile-overview.js @@ -0,0 +1,684 @@ +/** + * @fileoverview Phone home screen: a scrolling overview of what every session is + * doing, shown instead of the welcome overlay when the "C" logo is tapped. + * + * The welcome screen answers "how do I start something"; on a phone the more + * urgent question is "which of my sessions is blocked on me". This surface + * answers that first: NEEDS YOU (pending permission/question/idle hooks and + * errored sessions), then SPACES (cases, expandable to their sessions), then + * WORKING and IDLE / DONE. + * + * PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a + * popped-out solo window, per-device setting on). Tablet and desktop keep the + * welcome overlay untouched. The container ships with the `hidden` attribute and + * only this module removes it, so desktop (which never loads mobile.css) cannot + * render an unstyled overview even if a class rule leaked. + * + * Everything renders from state the page already holds (`this.sessions`, + * `this.cases`, `this.pendingHooks`) — no endpoint, no SSE event, no schema. + * `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts). + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run) + * @dependency mobile-handlers.js (MobileDetection) + * @dependency session-ui.js (selectQuickStartCase for "New session here") + * @loadorder 12.55 of 16, after webview-tabs.js, before entrance-animations.js + */ + +/** Viewport width that counts as a phone. Matches the mobile.css phone block. */ +const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)'; + +/** Sort rank per state: the most demanding thing sorts first inside a section. */ +const MOBILE_OVERVIEW_STATE_RANK = { + needs: 0, + error: 1, + waiting: 2, + working: 3, + idle: 4, + done: 5, +}; + +/** How many past conversations show before the "Show all" toggle. */ +const MOBILE_OVERVIEW_PAST_LIMIT = 8; + +/** + * Backends offered by the Run picker, mirroring the toolbar's run-mode menu + * (`#runModeMenu` in index.html). `short` is the badge on the Run button itself. + */ +const MOBILE_OVERVIEW_RUN_MODES = [ + { mode: 'claude', label: 'Claude Code', short: 'Claude' }, + { mode: 'opencode', label: 'OpenCode', short: 'OpenCode' }, + { mode: 'codex', label: 'Codex', short: 'Codex' }, + { mode: 'gemini', label: 'Gemini', short: 'Gemini' }, + { mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' }, + { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, +]; + +/** Pill copy per state. Kept short: a phone row has ~90px for it. */ +const MOBILE_OVERVIEW_PILL_LABEL = { + needs: 'needs you', + error: 'error', + waiting: 'waiting', + working: 'working', + idle: 'idle', + done: 'done', +}; + +Object.assign(CodemanApp.prototype, { + // ═══════════════════════════════════════════════════════════════ + // Model (pure) + // ═══════════════════════════════════════════════════════════════ + + /** + * Classify one session. + * Order matters: an action hook outranks everything (it is literally blocking + * the agent), and a pending idle_prompt outranks a stale 'busy' status because + * the hook is the newer signal. + * @param {object} session session state from this.sessions + * @param {Set|undefined} hooks pending hook types for that session + * @returns {'needs'|'error'|'waiting'|'working'|'idle'|'done'} + */ + _mobileOverviewState(session, hooks) { + if (hooks && (hooks.has('permission_prompt') || hooks.has('elicitation_dialog'))) return 'needs'; + if (session.status === 'error') return 'error'; + if (hooks && hooks.has('idle_prompt')) return 'waiting'; + if (session.status === 'busy') return 'working'; + if (session.status === 'stopped') return 'done'; + return 'idle'; + }, + + /** + * Longest-prefix match of a workingDir against the case list, so a session + * started in a subdirectory still belongs to its case. Mirrors the matching in + * `_resolveCaseLabel()` (terminal-ui.js) but returns the case itself. + * @returns {object|null} the matching case, or null when the dir is outside every case + */ + _mobileOverviewCaseFor(workingDir, cases) { + if (!workingDir) return null; + let best = null; + for (const c of cases || []) { + if (!c || !c.path) continue; + if (workingDir === c.path) return c; + if (workingDir.startsWith(c.path + '/') && (!best || c.path.length > best.path.length)) { + best = c; + } + } + return best; + }, + + /** + * Build the whole overview model. PURE: reads only its argument, touches no DOM + * and no `this` state, so it can be unit-tested against plain objects. + * + * @param {object} input + * @param {Map|Array} input.sessions live sessions (this.sessions) + * @param {Array} input.cases case list (this.cases) + * @param {Array} [input.sessionOrder] the user's tab order, used as the tiebreak + * @param {Map>} [input.pendingHooks] this.pendingHooks + * @param {Array} [input.history] unified session items (GET /api/sessions/unified) + * @returns {{needsYou: Array, current: Array, past: Array, sessionCount: number}} + */ + buildMobileOverviewModel(input) { + const cases = Array.isArray(input && input.cases) ? input.cases : []; + const order = Array.isArray(input && input.sessionOrder) ? input.sessionOrder : []; + const pendingHooks = (input && input.pendingHooks) || new Map(); + const raw = (input && input.sessions) || []; + const sessions = typeof raw.values === 'function' ? Array.from(raw.values()) : Array.from(raw); + + const rows = sessions.map((session) => { + const matched = this._mobileOverviewCaseFor(session.workingDir, cases); + const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id)); + const orderIndex = order.indexOf(session.id); + return { + id: session.id, + name: this.getSessionName ? this.getSessionName(session) : session.name || session.id.slice(0, 8), + mode: session.mode || 'claude', + caseName: matched ? matched.name : '', + dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '', + state, + pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state, + orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex, + }; + }); + + const bySeverityThenOrder = (a, b) => { + const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state]; + return rank !== 0 ? rank : a.orderIndex - b.orderIndex; + }; + + const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder); + + // Past = conversations from the unified list that are not currently live. + // The endpoint already folds a transcript into its owning session (via the + // claudeSessionId alias map), so a plain id check is enough to avoid listing + // a running session twice. + const liveIds = new Set(rows.map((r) => r.id)); + const past = (Array.isArray(input && input.history) ? input.history : []) + .filter((item) => item && item.sessionId && !liveIds.has(item.sessionId)) + .map((item) => { + const matched = this._mobileOverviewCaseFor(item.workingDir, cases); + const dir = item.workingDir || ''; + // The transcript reader emits the literal "(no content)" for a + // conversation it could not pull a prompt from; that is not a title. + const prompt = (item.firstPrompt || '').trim(); + const title = prompt && prompt !== '(no content)' ? prompt : ''; + return { + id: item.sessionId, + claudeSessionId: item.claudeSessionId || '', + workingDir: dir, + name: item.name || '', + title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8), + mode: item.mode || 'claude', + caseName: matched ? matched.name : '', + dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir, + at: item.lastActivityAt || item.createdAt || 0, + }; + }) + .sort((a, b) => b.at - a.at); + + return { + needsYou: inSection(['needs', 'error', 'waiting']), + current: inSection(['working', 'idle', 'done']), + past, + sessionCount: rows.length, + }; + }, + + // ═══════════════════════════════════════════════════════════════ + // Gate + visibility + // ═══════════════════════════════════════════════════════════════ + + /** + * Phone-only gate. Width-driven (not `isHandheldDevice()`): this is a LAYOUT + * decision, and an unfolded foldable with a tablet-width viewport should get + * the tablet welcome screen. Per-device settings identity is a separate + * question and deliberately stays handheld-based. + */ + shouldUseMobileOverview() { + if (this.isSoloWindow) return false; + const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {}; + if (settings.mobileOverviewEnabled === false) return false; + if (typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType) { + return MobileDetection.getDeviceType() === 'mobile'; + } + return !!(window.matchMedia && window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY).matches); + }, + + /** True while the overview is the visible home surface. */ + isMobileOverviewVisible() { + const el = document.getElementById('mobileOverview'); + return !!el && el.classList.contains('visible'); + }, + + showMobileOverview() { + const el = document.getElementById('mobileOverview'); + if (!el) return; + el.hidden = false; + el.classList.add('visible'); + this._wireMobileOverview(el); + this.renderMobileOverview(); + void this.loadMobileOverviewHistory(); + }, + + hideMobileOverview() { + const el = document.getElementById('mobileOverview'); + if (!el) return; + this._closeMobileOverviewRunMenu(); + el.classList.remove('visible'); + el.hidden = true; + }, + + /** + * Past conversations, fetched once per home-screen visit. The unified list is + * the same source the welcome screen resumes from, so a row resumed here and a + * row resumed there behave identically. Failures leave the section out rather + * than showing an error: the live sessions above it are the important part. + */ + async loadMobileOverviewHistory() { + if (this._mobileOverviewHistoryLoading) return; + this._mobileOverviewHistoryLoading = true; + try { + this._mobileOverviewHistory = await this._fetchUnifiedSessions(60); + } catch (err) { + console.warn('[mobile-overview] history load failed:', err); + this._mobileOverviewHistory = this._mobileOverviewHistory || []; + } finally { + this._mobileOverviewHistoryLoading = false; + if (this.isMobileOverviewVisible()) this.renderMobileOverview(); + } + }, + + /** Re-render only when the surface is actually showing (called from the tab renderer). */ + _refreshMobileOverviewIfVisible() { + if (!this.isMobileOverviewVisible()) return; + this._debouncedCall('mobileOverview', () => this.renderMobileOverview(), 150); + }, + + /** + * One delegated click listener for every row, plus a breakpoint listener so + * rotating or unfolding while on the home screen swaps to the right surface + * instead of stranding a phone layout on a tablet-width viewport. + */ + _wireMobileOverview(el) { + if (this._mobileOverviewWired) return; + this._mobileOverviewWired = true; + + el.addEventListener('click', (event) => { + const target = event.target && event.target.closest && event.target.closest('[data-mo-action]'); + if (!target) return; + const action = target.dataset.moAction; + if (action === 'session') { + this._closeMobileOverviewRunMenu(); + void this.selectSession(target.dataset.moSession); + } else if (action === 'resume') { + this._closeMobileOverviewRunMenu(); + void this.resumeMobileOverviewSession(target.dataset.moSession); + } else if (action === 'more-past') { + this._mobileOverviewShowAllPast = !this._mobileOverviewShowAllPast; + this.renderMobileOverview(); + } else if (action === 'run') { + this._closeMobileOverviewRunMenu(); + void this.run(); + } else if (action === 'run-menu') { + this._toggleMobileOverviewRunMenu(); + } else if (action === 'run-mode') { + // Picking a backend both selects it (so the Run button keeps meaning what + // you last chose, exactly like the toolbar) and launches it: on a phone + // the pick IS the intent to start. + this._closeMobileOverviewRunMenu(); + this.setRunMode(target.dataset.moMode); + void this.run(); + } else if (action === 'run-webview') { + this._closeMobileOverviewRunMenu(); + void this.openWebviewFromMenu(target.dataset.moWebview); + } else if (action === 'run-add-url') { + this._closeMobileOverviewRunMenu(); + this.showWebviewModal(); + } + }); + + if (window.matchMedia) { + const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY); + const onChange = () => { + // Only relevant while a home surface is up; entering a session re-decides + // through hideWelcome()/showWelcome() anyway. + if (this.activeSessionId) return; + if (typeof this.showWelcome === 'function') this.showWelcome(); + }; + if (mq.addEventListener) mq.addEventListener('change', onChange); + else if (mq.addListener) mq.addListener(onChange); + } + }, + + _toggleMobileOverviewRunMenu() { + this._mobileOverviewRunMenuOpen = !this._mobileOverviewRunMenuOpen; + this.renderMobileOverview(); + }, + + _closeMobileOverviewRunMenu() { + if (!this._mobileOverviewRunMenuOpen) return; + this._mobileOverviewRunMenuOpen = false; + if (this.isMobileOverviewVisible()) this.renderMobileOverview(); + }, + + /** + * Resume a past conversation. Delegates to the same resumeHistorySession() the + * welcome screen's Resume list uses, so name synthesis, envOverrides and the + * resumeSessionId wiring stay in one place. + */ + async resumeMobileOverviewSession(sessionId) { + const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId); + if (!row || !row.workingDir) return; + await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined); + }, + + // ═══════════════════════════════════════════════════════════════ + // Render + // ═══════════════════════════════════════════════════════════════ + + renderMobileOverview() { + const el = document.getElementById('mobileOverview'); + if (!el) return; + + const model = this.buildMobileOverviewModel({ + sessions: this.sessions, + cases: this.cases, + sessionOrder: this.sessionOrder, + pendingHooks: this.pendingHooks, + history: this._mobileOverviewHistory, + }); + // Resume needs the workingDir/claudeSessionId off the row the user tapped. + this._mobileOverviewPastRows = model.past; + + el.replaceChildren(); + el.appendChild(this._buildMobileOverviewTop()); + + if (model.needsYou.length) { + el.appendChild( + this._buildMobileOverviewSection( + 'Needs you', + model.needsYou.length, + model.needsYou.map((r) => this._buildMobileOverviewRow(r)) + ) + ); + } + + el.appendChild( + this._buildMobileOverviewSection( + 'Current sessions', + model.current.length, + model.current.map((r) => this._buildMobileOverviewRow(r)), + 'Nothing running. Hit Run to start something.' + ) + ); + + el.appendChild( + this._buildMobileOverviewSection( + 'Past sessions', + model.past.length, + this._buildMobileOverviewPast(model), + this._mobileOverviewHistory ? 'No past conversations yet' : 'Loading…' + ) + ); + }, + + /** + * Past conversations, newest first and capped: the unified list can run to + * dozens, and this section sits below the live ones on purpose. + */ + _buildMobileOverviewPast(model) { + const showAll = !!this._mobileOverviewShowAllPast; + const visible = showAll ? model.past : model.past.slice(0, MOBILE_OVERVIEW_PAST_LIMIT); + const children = visible.map((r) => this._buildMobileOverviewPastRow(r)); + const hiddenCount = model.past.length - visible.length; + if (hiddenCount > 0 || showAll) { + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'mobile-overview-more'; + toggle.dataset.moAction = 'more-past'; + const label = document.createElement('span'); + label.textContent = showAll ? 'Show fewer' : 'Show all past sessions'; + toggle.appendChild(label); + if (!showAll) { + const count = document.createElement('span'); + count.className = 'mobile-overview-more-count'; + count.setAttribute('data-i18n-skip', ''); + count.textContent = String(hiddenCount); + toggle.appendChild(count); + } + children.push(toggle); + } + return children; + }, + + _buildMobileOverviewTop() { + const wrap = document.createElement('div'); + wrap.className = 'mobile-overview-header'; + + const top = document.createElement('div'); + top.className = 'mobile-overview-top'; + + const brand = document.createElement('span'); + brand.className = 'mobile-overview-brand'; + brand.textContent = (window.CodemanI18n && window.CodemanI18n.displayName) || 'Codeman'; + brand.setAttribute('data-i18n-skip', ''); + top.appendChild(brand); + + // Split button carrying the TOOLBAR's own classes (`btn-toolbar btn-run + // mode-` / `btn-run-gear`), so the per-backend gradient, border and + // text color come from the same rules as the Run button in the toolbar and + // stay in sync with it for free. mobile.css only sizes it. + const group = document.createElement('div'); + group.className = 'mobile-overview-run-group'; + + const mode = this.runMode || 'claude'; + const run = document.createElement('button'); + run.className = `btn-toolbar btn-run mode-${mode} mobile-overview-run`; + run.type = 'button'; + run.dataset.moAction = 'run'; + const runLabel = document.createElement('span'); + runLabel.textContent = 'Run'; + run.appendChild(runLabel); + const runMode = document.createElement('span'); + runMode.className = 'mobile-overview-run-mode'; + runMode.setAttribute('data-i18n-skip', ''); + runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode; + run.appendChild(runMode); + group.appendChild(run); + + const caret = document.createElement('button'); + caret.className = `btn-toolbar btn-run-gear mode-${mode} mobile-overview-run-caret`; + caret.type = 'button'; + caret.dataset.moAction = 'run-menu'; + caret.setAttribute('aria-label', 'Choose what to run'); + caret.setAttribute('aria-expanded', String(!!this._mobileOverviewRunMenuOpen)); + // An SVG chevron, not a "⌄" glyph: the character carries its own baseline + // offset, so it sits visibly low in a flex-centered box no matter what the + // line-height says. A path is centered by geometry. Same shape the toolbar's + // run-mode gear uses. + caret.appendChild(this._buildMobileOverviewChevron()); + group.appendChild(caret); + + top.appendChild(group); + wrap.appendChild(top); + + if (this._mobileOverviewRunMenuOpen) wrap.appendChild(this._buildMobileOverviewRunMenu()); + return wrap; + }, + + /** Down chevron as SVG (see the note at its call site). */ + _buildMobileOverviewChevron() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2.5'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M6 9l6 6 6-6'); + svg.appendChild(path); + return svg; + }, + + /** + * The Run picker: the same backends as the toolbar's run-mode menu, plus saved + * web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu: + * past conversations have their own section further down this screen. + */ + _buildMobileOverviewRunMenu() { + const menu = document.createElement('div'); + menu.className = 'mobile-overview-run-menu'; + const current = this.runMode || 'claude'; + + for (const entry of MOBILE_OVERVIEW_RUN_MODES) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : ''); + option.dataset.moAction = 'run-mode'; + option.dataset.moMode = entry.mode; + const dot = document.createElement('span'); + dot.className = 'run-mode-dot ' + entry.mode; + dot.setAttribute('aria-hidden', 'true'); + option.appendChild(dot); + const label = document.createElement('span'); + label.textContent = entry.label; + option.appendChild(label); + menu.appendChild(option); + } + + const header = document.createElement('div'); + header.className = 'mobile-overview-run-header'; + header.textContent = 'Web / URL'; + menu.appendChild(header); + + for (const webview of this.webviews ? this.webviews.values() : []) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'mobile-overview-run-option'; + option.dataset.moAction = 'run-webview'; + option.dataset.moWebview = webview.id; + const dot = document.createElement('span'); + dot.className = 'run-mode-dot web'; + dot.setAttribute('aria-hidden', 'true'); + option.appendChild(dot); + const label = document.createElement('span'); + // A dashboard name is user content. + label.className = 'case-name'; + label.textContent = webview.name; + option.appendChild(label); + menu.appendChild(option); + } + + const add = document.createElement('button'); + add.type = 'button'; + add.className = 'mobile-overview-run-option mobile-overview-run-option--add'; + add.dataset.moAction = 'run-add-url'; + const addDot = document.createElement('span'); + addDot.className = 'run-mode-dot web'; + addDot.setAttribute('aria-hidden', 'true'); + add.appendChild(addDot); + const addLabel = document.createElement('span'); + addLabel.textContent = 'Add URL…'; + add.appendChild(addLabel); + menu.appendChild(add); + + return menu; + }, + + _buildMobileOverviewSection(title, count, children, emptyText) { + const section = document.createElement('section'); + section.className = 'mobile-overview-section'; + + const heading = document.createElement('h2'); + heading.className = 'mobile-overview-heading'; + const label = document.createElement('span'); + label.textContent = title; + heading.appendChild(label); + const badge = document.createElement('span'); + badge.className = 'mobile-overview-heading-count'; + badge.textContent = String(count); + badge.setAttribute('data-i18n-skip', ''); + heading.appendChild(badge); + section.appendChild(heading); + + if (!children.length && emptyText) { + const empty = document.createElement('p'); + empty.className = 'mobile-overview-empty'; + empty.textContent = emptyText; + section.appendChild(empty); + return section; + } + for (const child of children) section.appendChild(child); + return section; + }, + + /** + * A session row. The state class drives the same visual language as the + * session tabs: green dot when it is fine (pulsing while working), a yellow + * blinking row when it wants input, a red blinking row when it asked a + * question. Anything else here would mean two different meanings for the same + * colors on one screen. + */ + _buildMobileOverviewRow(row) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'mobile-overview-row mobile-overview-row--' + row.state; + item.dataset.moAction = 'session'; + item.dataset.moSession = row.id; + + const dot = document.createElement('span'); + dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state; + dot.setAttribute('aria-hidden', 'true'); + item.appendChild(dot); + + const body = document.createElement('span'); + body.className = 'mobile-overview-row-body'; + + const line1 = document.createElement('span'); + line1.className = 'mobile-overview-row-title'; + const name = document.createElement('span'); + // .session-name is in the i18n skip list: a session name is user content. + name.className = 'session-name'; + name.textContent = row.name; + line1.appendChild(name); + if (row.caseName) { + const meta = document.createElement('span'); + meta.className = 'mobile-overview-row-case case-name'; + meta.textContent = ' · ' + row.caseName; + line1.appendChild(meta); + } + body.appendChild(line1); + + const line2 = document.createElement('span'); + line2.className = 'mobile-overview-row-sub'; + line2.setAttribute('data-i18n-skip', ''); + line2.textContent = row.mode + (row.dir ? ' · ' + row.dir : ''); + body.appendChild(line2); + + item.appendChild(body); + + const pill = document.createElement('span'); + pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state; + // Skipped by i18n on purpose: the labels are generic single words ("idle", + // "done", "error") that collide with state strings on other surfaces. + pill.setAttribute('data-i18n-skip', ''); + pill.textContent = row.pill; + item.appendChild(pill); + + const chevron = document.createElement('span'); + chevron.className = 'mobile-overview-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + chevron.textContent = '›'; + item.appendChild(chevron); + + return item; + }, + + /** A past conversation. Tapping it resumes, which creates a fresh session. */ + _buildMobileOverviewPastRow(row) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'mobile-overview-row mobile-overview-row--past'; + item.dataset.moAction = 'resume'; + item.dataset.moSession = row.id; + + const dot = document.createElement('span'); + dot.className = 'mobile-overview-dot mobile-overview-dot--past'; + dot.setAttribute('aria-hidden', 'true'); + item.appendChild(dot); + + const body = document.createElement('span'); + body.className = 'mobile-overview-row-body'; + + const title = document.createElement('span'); + // A first prompt is user content, never app copy. + title.className = 'mobile-overview-row-title session-name'; + title.textContent = row.title; + body.appendChild(title); + + const sub = document.createElement('span'); + sub.className = 'mobile-overview-row-sub'; + sub.setAttribute('data-i18n-skip', ''); + const when = row.at && this._formatTimeAgo ? this._formatTimeAgo(row.at) : ''; + sub.textContent = [row.caseName || row.dir, when].filter(Boolean).join(' · '); + body.appendChild(sub); + + item.appendChild(body); + + const pill = document.createElement('span'); + pill.className = 'mobile-overview-pill mobile-overview-pill--past'; + pill.textContent = 'resume'; + pill.setAttribute('data-i18n-skip', ''); + item.appendChild(pill); + + const chevron = document.createElement('span'); + chevron.className = 'mobile-overview-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + chevron.textContent = '›'; + item.appendChild(chevron); + + return item; + }, +}); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9f199dd8..8fe97551 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2259,6 +2259,433 @@ html.mobile-init .file-browser-panel { border-radius: 8px; transform: none !important; } + + /* ---- Mobile Overview (phone home screen) ---- + Shown in place of the welcome overlay when the C logo is tapped. Styled only + with :root tokens so every skin, including the four light ones, works with + no override block. Never give .mobile-overview itself a display value: the + element ships with [hidden] and only .visible may turn it on. */ + + .mobile-overview.visible { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem 0.6rem calc(1rem + var(--safe-area-bottom)); + background: var(--bg-dark); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + } + + /* No side padding: the wordmark's left edge lines up with the session cards + below it, and the Run button's right edge with theirs. */ + .mobile-overview-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0; + } + + /* Same accent as the "C" in the header (`.logo` uses --accent-hover), and the + same 48px box as the Run button opposite it, so the two are centered on one + line by construction rather than by eye. */ + .mobile-overview-brand { + display: inline-flex; + align-items: center; + min-height: 48px; + font-size: 1.5rem; + font-weight: 800; + line-height: 1; + color: var(--accent-hover); + letter-spacing: -0.02em; + } + + /* Split Run button. Colors come from the toolbar's own + `.btn-toolbar.btn-run.mode-` rules in styles.css (the element + carries those classes), so this button and the one in the toolbar are the + same control in two places. Only size/shape is set here, and NOTHING that + would override the mode gradient. */ + .mobile-overview-header { + position: relative; + display: flex; + flex-direction: column; + } + + .mobile-overview-run-group { + display: flex; + align-items: stretch; + flex-shrink: 0; + } + + /* Sized well past the 44px touch minimum: this is the primary action on the + home screen, and the caret is a separate target right next to it. + ⚠️ The !important is required, not decorative: the phone block clamps every + .btn-toolbar to `height/min-height/max-height: 26px !important`, and this + button deliberately carries .btn-toolbar to inherit the run-mode gradient. + Without matching !important (max-height included) it renders 26px tall. */ + .mobile-overview-run-group .mobile-overview-run { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 48px !important; + height: 48px !important; + max-height: 48px !important; + padding: 0 1.15rem !important; + font-size: 0.95rem !important; + line-height: 1; + font-weight: 700; + font-family: inherit; + border-radius: 10px 0 0 10px !important; + } + + .mobile-overview-run-mode { + font-weight: 600; + font-size: 0.85rem; + opacity: 0.8; + } + + .mobile-overview-run-group .mobile-overview-run-caret { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px !important; + height: 48px !important; + max-height: 48px !important; + min-width: 48px !important; + width: 48px !important; + padding: 0 !important; + line-height: 1; + font-family: inherit; + border-radius: 0 10px 10px 0 !important; + } + + /* The phone block shrinks every run-gear glyph to 10px; this one is a 48px + tap target, so it gets a proportionate chevron. `display:block` drops the + inline-baseline gap that would otherwise push it a pixel low. */ + .mobile-overview-run-group .mobile-overview-run-caret svg { + display: block; + width: 20px !important; + height: 20px !important; + margin: 0 !important; + } + + .mobile-overview-run-menu { + display: flex; + flex-direction: column; + gap: 0.15rem; + margin: 0.5rem 0 0; + padding: 0.35rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--floating-bg); + box-shadow: var(--elevated-shadow); + } + + .mobile-overview-run-option { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 44px; + padding: 0 0.6rem; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text); + font-family: inherit; + font-size: 0.82rem; + text-align: left; + } + + .mobile-overview-run-option.selected { + background: var(--control-bg-hover); + } + + .mobile-overview-run-option:active { + background: var(--bg-hover); + } + + .mobile-overview-run-option--add { + color: var(--accent); + } + + .mobile-overview-run-header { + padding: 0.4rem 0.6rem 0.2rem; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); + } + + .mobile-overview-section { + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + /* Flush left with the wordmark and the cards: one left edge down the screen. */ + .mobile-overview-heading { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0.35rem 0 0; + padding: 0; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); + } + + .mobile-overview-heading-count { + color: var(--text-dim); + font-weight: 600; + } + + .mobile-overview-empty { + margin: 0; + padding: 0.5rem 0; + font-size: 0.75rem; + color: var(--text-muted); + } + + /* Row: 56px tap target, dot + two-line body + pill + chevron */ + .mobile-overview-row { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + min-height: 56px; + padding: 0.5rem 0.7rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-card); + color: var(--text); + font-family: inherit; + text-align: left; + } + + .mobile-overview-row:active { + background: var(--bg-hover); + } + + /* Attention states mirror the session tabs exactly: red blink when the agent + asked something (permission / question), yellow blink when it is waiting for + a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in + styles.css; the keyframes are re-declared here only because a tab's resting + background is transparent while a row's is the card color. */ + .mobile-overview-row--needs { + border-color: var(--red); + animation: mobile-overview-blink-red 2.5s ease-in-out infinite; + } + + .mobile-overview-row--waiting { + border-color: var(--yellow); + animation: mobile-overview-blink-yellow 3.5s ease-in-out infinite; + } + + .mobile-overview-row--error { + border-color: var(--red); + } + + @keyframes mobile-overview-blink-red { + 0%, + 100% { + background: var(--bg-card); + border-color: var(--border); + } + 50% { + background: rgba(239, 68, 68, 0.14); + border-color: var(--red); + } + } + + @keyframes mobile-overview-blink-yellow { + 0%, + 100% { + background: var(--bg-card); + border-color: var(--border); + } + 50% { + background: rgba(234, 179, 8, 0.12); + border-color: var(--yellow); + } + } + + .mobile-overview-row-body { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; + min-width: 0; + } + + .mobile-overview-row-title { + display: block; + font-size: 0.9rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mobile-overview-row-case { + color: var(--text-dim); + font-weight: 500; + } + + .mobile-overview-row-sub { + display: block; + font-size: 0.68rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .mobile-overview-dot { + flex-shrink: 0; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--text-muted); + } + + .mobile-overview-dot--needs, + .mobile-overview-dot--error { + background: var(--red); + } + + .mobile-overview-dot--waiting { + background: var(--yellow); + } + + /* Same as .session-tab .tab-status: green when the session is fine, and the + shared `pulse` keyframes while it is working. */ + .mobile-overview-dot--working { + background: var(--green); + animation: pulse 1.5s infinite; + will-change: opacity; + } + + .mobile-overview-dot--idle { + background: var(--green); + } + + .mobile-overview-dot--done { + background: var(--text-muted); + opacity: 0.5; + } + + .mobile-overview-pill { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.45rem; + border: 1px solid var(--border-light); + border-radius: 999px; + font-size: 0.62rem; + font-weight: 600; + color: var(--text-dim); + white-space: nowrap; + } + + .mobile-overview-pill--needs, + .mobile-overview-pill--error { + border-color: var(--red); + color: var(--red); + } + + .mobile-overview-pill--waiting { + border-color: var(--yellow); + color: var(--yellow); + } + + .mobile-overview-pill--idle, + .mobile-overview-pill--working { + border-color: var(--green); + color: var(--green); + } + + .mobile-overview-chevron { + flex-shrink: 0; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; + } + + /* Past conversations: quieter than a live row, since tapping one starts work */ + .mobile-overview-row--past { + background: transparent; + border-style: dashed; + } + + .mobile-overview-row--past .mobile-overview-row-title { + font-weight: 500; + color: var(--text-dim); + } + + .mobile-overview-dot--past { + background: transparent; + border: 1px solid var(--border-light); + } + + .mobile-overview-pill--past { + border-color: var(--border-light); + color: var(--text-muted); + } + + .mobile-overview-more { + display: flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 40px; + border: none; + border-radius: 10px; + background: var(--control-bg); + color: var(--text-dim); + font-family: inherit; + font-size: 0.72rem; + font-weight: 600; + } + + .mobile-overview-more-count { + padding: 0.05rem 0.35rem; + border-radius: 999px; + background: var(--control-bg-hover); + color: var(--text-muted); + } +} + +/* The overview's attention blink is an alert, so it stays visible without + motion: hold the alert color instead of animating to it. */ +@media (prefers-reduced-motion: reduce) { + .mobile-overview-row--needs, + .mobile-overview-row--waiting { + animation: none; + } + + .mobile-overview-row--needs { + background: rgba(239, 68, 68, 0.14); + } + + .mobile-overview-row--waiting { + background: rgba(234, 179, 8, 0.12); + } + + .mobile-overview-dot--working { + animation: none; + } } /* Light-skin compatibility for mobile-only chrome. These components predate diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index d74556b3..0c387b86 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -316,6 +316,9 @@ Object.assign(CodemanApp.prototype, { select.dataset.listenerAdded = 'true'; } this.setupQuickStartCasePicker(); + // The phone overview labels rows with their case name, and a case rename or + // link does not go through the session-tab renderer. + this._refreshMobileOverviewIfVisible?.(); } catch (err) { console.error('Failed to load cases:', err); } diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d04c047b..36d06214 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -331,6 +331,14 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings); document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; + // Phone overview home screen: only meaningful under 430px, so the row is + // hidden elsewhere rather than offering a toggle that changes nothing. + document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false; + const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none'; + const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem'); + if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly; + const phoneSection = document.getElementById('appSettingsPhoneSection'); + if (phoneSection) phoneSection.style.display = phoneOnly; // Session Manager, Away Digest and Cron buttons all default OFF (opt-in under // Display → Header Displays; the Cron button also ships with btn-cron--hidden // in the template, so an unchecked box and a hidden button stay consistent). @@ -1453,6 +1461,7 @@ Object.assign(CodemanApp.prototype, { showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked, showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked, + mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked, showSessionButton: document.getElementById('appSettingsShowSessionButton').checked, showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked, showCronButton: document.getElementById('appSettingsShowCronButton').checked, @@ -1616,6 +1625,10 @@ Object.assign(CodemanApp.prototype, { // Apply CJK input visibility immediately this._updateCjkInputState(); + // The phone home surface (overview vs welcome) may have just been toggled. + // Only re-decide while a home screen is actually up. + if (!this.activeSessionId) this.showWelcome(); + // Apply keyboard bar mode KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple'); @@ -1646,6 +1659,8 @@ Object.assign(CodemanApp.prototype, { showSessionButton: _ssb, showAwayDigestButton: _adb, showCronButton: _crb, + // Phone-only home surface, and absent from SettingsUpdateSchema (.strict()). + mobileOverviewEnabled: _mov, ...serverSettings } = settings; try { @@ -1814,6 +1829,10 @@ Object.assign(CodemanApp.prototype, { showSessionButton: false, showAwayDigestButton: false, showCronButton: false, + // Phone home screen: the C logo opens the session overview instead of the + // welcome screen. ON by default here, and the escape hatch if it ever + // misbehaves on a device (the gate treats only an explicit false as off). + mobileOverviewEnabled: true, // Remote auto-reconnect (COD-108) — on by default remoteAutoReconnect: true, // Input @@ -2269,6 +2288,7 @@ Object.assign(CodemanApp.prototype, { 'language', 'terminalWheelLocalScrollback', 'showSessionButton', 'showAwayDigestButton', 'showCronButton', + 'mobileOverviewEnabled', ]); // The plan-usage chip is a PER-DEVICE display setting (desktop default ON, // handheld default OFF): desktop can show it while mobile stays hidden. It diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3a9de819..f19f37ee 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1178,6 +1178,19 @@ Object.assign(CodemanApp.prototype, { }, showWelcome() { + // Phones get the session overview instead of the welcome screen: on a small + // screen "which session is blocked on me" beats "how do I start one". The + // gate lives in mobile-overview.js; every other device falls through + // unchanged. Both surfaces are toggled here so a breakpoint change (rotate, + // unfold) swaps cleanly instead of showing both. + if (this.shouldUseMobileOverview?.()) { + const overlay = document.getElementById('welcomeOverlay'); + if (overlay) overlay.classList.remove('visible'); + this.showMobileOverview(); + this._updateCjkInputState?.(); + return; + } + this.hideMobileOverview?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.add('visible'); @@ -1191,6 +1204,7 @@ Object.assign(CodemanApp.prototype, { }, hideWelcome() { + this.hideMobileOverview?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts new file mode 100644 index 00000000..14221c99 --- /dev/null +++ b/test/mobile-overview.test.ts @@ -0,0 +1,274 @@ +// Port: none (pure model + static markup assertions — no browser, no server). +// +// The phone home screen (src/web/public/mobile-overview.js) replaces the welcome +// overlay under 430px. Its grouping logic is the part that can silently go wrong: +// a session blocked on a permission prompt landing in "idle" is exactly the bug +// this surface exists to prevent. buildMobileOverviewModel() is pure for that +// reason, so it can be exercised here against plain objects. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); + +function loadOverviewApp(overrides: Record = {}) { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + console, + window: {}, + document: { getElementById: () => null }, + MobileDetection: { getDeviceType: () => 'mobile' }, + }); + vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, { + filename: 'mobile-overview.js', + }); + + const app = new (CodemanApp as any)(); + app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8); + app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/'); + app.loadAppSettingsFromStorage = () => ({}); + Object.assign(app, overrides); + return app; +} + +const CASES = [ + { name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' }, + { name: 'beta', path: '/home/arkon/codeman-cases/beta', location: 'local' }, + { name: 'boxed', path: '/srv/boxed', location: 'docker' }, +]; + +function session(over: Record) { + return { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over }; +} + +describe('mobile overview model', () => { + it('routes a session with a pending permission prompt into NEEDS YOU, not idle', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + sessions: [session({ id: 'a', status: 'idle' })], + cases: CASES, + pendingHooks: new Map([['a', new Set(['permission_prompt'])]]), + }); + + expect(model.needsYou.map((r: any) => r.id)).toEqual(['a']); + expect(model.current).toHaveLength(0); + expect(model.needsYou[0].state).toBe('needs'); + expect(model.needsYou[0].pill).toBe('needs you'); + }); + + it('ranks an action hook above an idle hook above a stale busy status', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + // An idle_prompt hook on a session the server still calls 'busy': the hook + // is the newer signal, so it must win. + sessions: [ + session({ id: 'busy-with-idle-hook', status: 'busy' }), + session({ id: 'elicit', status: 'busy' }), + session({ id: 'plain-busy', status: 'busy' }), + ], + cases: CASES, + pendingHooks: new Map([ + ['busy-with-idle-hook', new Set(['idle_prompt'])], + ['elicit', new Set(['elicitation_dialog'])], + ]), + }); + + expect(model.needsYou.map((r: any) => r.id)).toEqual(['elicit', 'busy-with-idle-hook']); + expect(model.current.map((r: any) => r.id)).toEqual(['plain-busy']); + }); + + it('buckets busy / idle / stopped / error and labels each pill', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + sessions: [ + session({ id: 'w', status: 'busy' }), + session({ id: 'i', status: 'idle' }), + session({ id: 'd', status: 'stopped' }), + session({ id: 'e', status: 'error' }), + ], + cases: CASES, + }); + + // Everything that is not blocked on you shares one "current" section, + // most demanding first. + expect(model.current.map((r: any) => [r.id, r.pill])).toEqual([ + ['w', 'working'], + ['i', 'idle'], + ['d', 'done'], + ]); + expect(model.needsYou.map((r: any) => r.pill)).toEqual(['error']); + expect(model.sessionCount).toBe(4); + }); + + it('keeps the user tab order as the tiebreak inside a section', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })], + cases: CASES, + sessionOrder: ['third', 'first', 'second'], + }); + + expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']); + }); + + it('matches a session started in a subdirectory to its case (longest prefix)', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + sessions: [ + session({ id: 'sub', workingDir: '/home/arkon/default/claudeman/src/web' }), + session({ id: 'outside', workingDir: '/tmp/scratch' }), + ], + cases: [...CASES, { name: 'claudeman-web', path: '/home/arkon/default/claudeman/src/web' }], + }); + + const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r.caseName])); + expect(rows.sub).toBe('claudeman-web'); + expect(rows.outside).toBe(''); + }); + + it('lists past conversations newest first and never repeats a live session', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + sessions: [session({ id: 'live-1' })], + cases: CASES, + history: [ + // Same id as the running session: the unified list includes live rows, + // and showing one in both sections would be a duplicate. + { sessionId: 'live-1', workingDir: '/home/arkon/default/claudeman', lastActivityAt: 500 }, + { + sessionId: 'old-a', + workingDir: '/home/arkon/codeman-cases/beta', + firstPrompt: 'fix the mobile header', + claudeSessionId: 'claude-uuid-a', + lastActivityAt: 100, + }, + { + sessionId: 'old-b', + workingDir: '/home/arkon/default/claudeman', + name: 'w4-claudeman', + lastActivityAt: 400, + }, + ], + }); + + expect(model.past.map((r: any) => r.id)).toEqual(['old-b', 'old-a']); + expect(model.past[1]).toMatchObject({ + title: 'fix the mobile header', + caseName: 'beta', + claudeSessionId: 'claude-uuid-a', + workingDir: '/home/arkon/codeman-cases/beta', + }); + // A row with no prompt falls back to its name, so it is never a bare UUID. + expect(model.past[0].title).toBe('w4-claudeman'); + }); + + it('does not title a past row with the transcript reader placeholder', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + sessions: [], + cases: CASES, + history: [ + { sessionId: 'blank', workingDir: '/home/arkon/default/claudeman', firstPrompt: '(no content)' }, + { sessionId: 'spaces', workingDir: '/home/arkon/codeman-cases/beta', firstPrompt: ' ' }, + ], + }); + + expect(model.past.map((r: any) => r.title)).toEqual(['claudeman', 'beta']); + }); + + it('accepts the live Map as-is and survives an empty state', () => { + const app = loadOverviewApp(); + const fromMap = app.buildMobileOverviewModel({ + sessions: new Map([['a', session({ id: 'a' })]]), + cases: CASES, + }); + expect(fromMap.current.map((r: any) => r.id)).toEqual(['a']); + + const empty = app.buildMobileOverviewModel({}); + expect(empty).toMatchObject({ needsYou: [], current: [], past: [], sessionCount: 0 }); + }); + + it('no longer builds a spaces section', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ sessions: [session({ id: 'a' })], cases: CASES }); + expect(model.spaces).toBeUndefined(); + }); +}); + +describe('mobile overview gate', () => { + it('is phone-width only, off in solo windows, and off when explicitly disabled', () => { + expect(loadOverviewApp().shouldUseMobileOverview()).toBe(true); + expect(loadOverviewApp({ isSoloWindow: true }).shouldUseMobileOverview()).toBe(false); + expect( + loadOverviewApp({ + loadAppSettingsFromStorage: () => ({ mobileOverviewEnabled: false }), + }).shouldUseMobileOverview() + ).toBe(false); + // An unset value must read as ON: phones that already have saved settings + // from before this feature existed have no key for it. + expect(loadOverviewApp({ loadAppSettingsFromStorage: () => ({ skin: 'og' }) }).shouldUseMobileOverview()).toBe( + true + ); + }); +}); + +describe('mobile overview wiring', () => { + const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'); + const moduleSrc = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'); + + it('speaks the same status language as the session tabs', () => { + // A session that is fine reads green on the tabs; anything else here would + // mean two meanings for one color on the same screen. + expect(mobileCss).toMatch(/\.mobile-overview-dot--idle\s*\{\s*background:\s*var\(--green\)/); + expect(mobileCss).toMatch(/\.mobile-overview-dot--working\s*\{[^}]*var\(--green\)[^}]*animation:\s*pulse/); + // Waiting-for-input blinks yellow, asked-a-question blinks red, same as + // tab-alert-idle / tab-alert-action. + expect(mobileCss).toMatch(/\.mobile-overview-row--waiting\s*\{[^}]*animation:\s*mobile-overview-blink-yellow/); + expect(mobileCss).toMatch(/\.mobile-overview-row--needs\s*\{[^}]*animation:\s*mobile-overview-blink-red/); + expect(mobileCss).toContain('@keyframes mobile-overview-blink-red'); + expect(mobileCss).toContain('@keyframes mobile-overview-blink-yellow'); + // The alert must survive reduced-motion as a held color, not vanish. + expect(mobileCss).toMatch(/prefers-reduced-motion[^}]*\}[\s\S]*?\.mobile-overview-row--needs/); + }); + + it('reuses the toolbar Run button classes instead of its own palette', () => { + // The per-backend gradient lives in styles.css keyed on + // `.btn-toolbar.btn-run.mode-` (and light skins override exactly + // those); carrying the same classes keeps both Run buttons identical. + expect(moduleSrc).toContain('btn-toolbar btn-run mode-'); + expect(moduleSrc).toContain('btn-toolbar btn-run-gear mode-'); + // The two button rules (not the dropdown below them) must set no color at + // all, or they would win over the mode gradient. + const buttonRules = mobileCss.match(/\.mobile-overview-run(-caret)?\s*\{[^}]*\}/g) || []; + expect(buttonRules.length).toBe(2); + for (const rule of buttonRules) { + expect(rule).not.toMatch(/\b(background|color)\s*:/); + } + }); + + it('ships the container hidden and loads the module', () => { + expect(html).toMatch(/