mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: version packages
Release 1.9.8 (aicodeman) and 0.1.8 (xterm-zerolag-input). Fixes macOS session start (`posix_spawnp failed.`, issues #6 and #204): node-pty ships its macOS spawn-helper as mode 0644 and macOS launches every PTY through it. `scripts/fix-node-pty.mjs` (npm run fix:node-pty) chmods every helper, prebuilds/ included, then verifies by really opening a PTY; the blind Node-22+ rebuild is gone. `spawnPtyWithHelperRepair()` self-heals an already broken install on the first failed spawn. Adds the phone home screen (session overview under 430px, per-device `mobileOverviewEnabled`, default ON) and a guided Tailscale path in install.sh, plus `install.sh tailscale` to retrofit it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,34 @@
|
|||||||
# aicodeman
|
# 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-<arch>/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 `<pkg>/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 <port>`, 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
|
## 1.9.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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`
|
- **`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`
|
- **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)
|
- **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-<arch>/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/<platform>-<arch>/`**, 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)
|
- **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.
|
**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 |
|
| **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 |
|
| **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` | |
|
| **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 |
|
| **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`.
|
★ = 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
|
||||||
|
|
||||||
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 `<html>`. 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`.
|
**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 `<html>`. 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-<backend>` / `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)
|
**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.
|
**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
|
## Scripts & Tunnel
|
||||||
|
|
||||||
**`install.sh`** (repo root, 69KB) is the public entry point: `curl -fsSL <raw url> | 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 <raw url> | 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 <port>` 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.
|
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.
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ codeman web --https
|
|||||||
# Open on your phone: https://<your-ip>:3000
|
# Open on your phone: https://<your-ip>: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://<tailscale-ip>: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://<your-machine>.<tailnet>.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
|
### Secure QR Code Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
**`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-<arch>/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/<platform>-<arch>/`, not just `build/Release/`.** node-pty's loader (`lib/utils.js`) tries `build/Release`, `build/Debug`, then `prebuilds/<platform>-<arch>`, 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')` (= `<pkg>/lib/index.js`), producing `<pkg>/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
|
## Tooling traps
|
||||||
|
|
||||||
### Headless screenshot capture
|
### Headless screenshot capture
|
||||||
|
|||||||
@@ -249,16 +249,28 @@ Ordered most‑to‑least recommended:
|
|||||||
|
|
||||||
### A. Tailscale serve (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
|
```bash
|
||||||
codeman web --https # binds 127.0.0.1:3000
|
bash ~/.codeman/app/install.sh tailscale
|
||||||
tailscale serve --bg https / http://127.0.0.1:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Only devices on your tailnet can reach it; Tailscale handles identity. No app
|
The guided flow installs Tailscale if needed, walks through login and the
|
||||||
password and no `0.0.0.0` bind required. (This is the maintainer's production
|
tailnet HTTPS-certificates toggle, and configures the equivalent of:
|
||||||
setup.)
|
|
||||||
|
```bash
|
||||||
|
codeman web # binds 127.0.0.1:3000 (plain HTTP is fine here)
|
||||||
|
tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.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
|
### B. Authenticated cloudflared tunnel + password
|
||||||
|
|
||||||
|
|||||||
@@ -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://<node>.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
|
||||||
|
`<node>.<tailnet>.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://<dnsname>/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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Web tabs: two fixes (planned + implemented 2026-07-28)
|
# Web tabs: two fixes (planned + implemented 2026-07-28)
|
||||||
|
|
||||||
Both found against the saved dashboard
|
Both found against the saved dashboard
|
||||||
`https://macminis-mac-mini.tailf80371.ts.net:4000` (Bio-Hacking-Dashboard).
|
`https://<your-host>.<your-tailnet>.ts.net:4000` (Bio-Hacking-Dashboard).
|
||||||
Kept because the root-cause analysis of the second one is not obvious from the
|
Kept because the root-cause analysis of the second one is not obvious from the
|
||||||
resulting diff.
|
resulting diff.
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ already revoked the capability and broadcast `WebviewChanged`.
|
|||||||
```
|
```
|
||||||
CAP=<from POST /api/webviews/<id>/open>
|
CAP=<from POST /api/webviews/<id>/open>
|
||||||
# A) upstream direct -> 200 image/jpeg 118150
|
# 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://<your-host>.<your-tailnet>.ts.net:4000/api/hero?slug=120-minutes-in-nature"
|
||||||
# B) through the proxy prefix -> 200 image/jpeg 118150
|
# B) through the proxy prefix -> 200 image/jpeg 118150
|
||||||
curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature"
|
curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature"
|
||||||
# C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"}
|
# C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"}
|
||||||
|
|||||||
+560
-19
@@ -21,6 +21,16 @@
|
|||||||
# non-interactive default is 127.0.0.1)
|
# non-interactive default is 127.0.0.1)
|
||||||
# CODEMAN_PASSWORD - Preset the dashboard password (skips the
|
# CODEMAN_PASSWORD - Preset the dashboard password (skips the
|
||||||
# password prompt when binding to the network)
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -51,6 +61,14 @@ EXISTING_HOST=""
|
|||||||
EXISTING_PASSWORD=""
|
EXISTING_PASSWORD=""
|
||||||
EXISTING_ACK="0"
|
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
|
# 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.
|
# ~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
|
# 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 " For access from OUTSIDE your network, prefer Tailscale or a tunnel."
|
||||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
||||||
else
|
else
|
||||||
echo -e " ${YELLOW}${BOLD}Security:${NC}"
|
# Loopback bind: when a tailscale serve mapping fronts it, lead with
|
||||||
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
|
# the actual URL instead of the generic "do ONE of" list. Detection is
|
||||||
echo -e " To reach it from another device, do ONE of:"
|
# dynamic (tailscaled state is the single source of truth).
|
||||||
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
|
local notice_ts_url="$TAILSCALE_SERVE_URL"
|
||||||
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
|
if [[ -z "$notice_ts_url" ]]; then
|
||||||
echo -e " A non-loopback bind without a password still starts, but warns loudly."
|
notice_ts_url=$(detect_tailscale_serve_url 2>/dev/null) || notice_ts_url=""
|
||||||
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
|
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
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
@@ -1050,6 +1083,7 @@ read_existing_binding() {
|
|||||||
# preset. The server binary itself still defaults to 127.0.0.1 either way.
|
# preset. The server binary itself still defaults to 127.0.0.1 either way.
|
||||||
choose_network_binding() {
|
choose_network_binding() {
|
||||||
# Preset via environment: honor it and skip the prompt entirely.
|
# 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
|
if [[ -n "${CODEMAN_HOST:-}" ]]; then
|
||||||
BIND_HOST="$CODEMAN_HOST"
|
BIND_HOST="$CODEMAN_HOST"
|
||||||
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
|
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
|
||||||
@@ -1057,6 +1091,20 @@ choose_network_binding() {
|
|||||||
BIND_ACK="1"
|
BIND_ACK="1"
|
||||||
fi
|
fi
|
||||||
info "Network binding preset via CODEMAN_HOST: $BIND_HOST"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1077,21 +1125,48 @@ choose_network_binding() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Default follows the existing setup when there is one, else network.
|
# Tailscale state, for the menu hint and the default choice. Detection
|
||||||
local default_choice="1"
|
# 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
|
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
|
fi
|
||||||
|
|
||||||
echo -e " ${BOLD}Network access${NC}"
|
echo -e " ${BOLD}Network access${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " How should the Codeman dashboard be reachable?"
|
echo -e " How should the Codeman dashboard be reachable?"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}1)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC}"
|
echo -e " ${CYAN}1)${NC} ${BOLD}Tailscale${NC} ${DIM}($ts_hint)${NC}"
|
||||||
echo -e " Open it straight from your phone or laptop."
|
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 " ${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 " ${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."
|
echo -e " Safest. Reach it remotely via Tailscale or a tunnel later."
|
||||||
echo ""
|
echo ""
|
||||||
if [[ "$EXISTING_FOUND" == "1" ]]; then
|
if [[ "$EXISTING_FOUND" == "1" ]]; then
|
||||||
echo -e " ${DIM}Current setup: $EXISTING_HOST$([[ -n "$EXISTING_PASSWORD" ]] && echo ", password set"). Enter keeps it.${NC}"
|
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=""
|
local bind_choice=""
|
||||||
while true; do
|
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"
|
read_reply bind_choice || bind_choice="$default_choice"
|
||||||
bind_choice="${bind_choice:-$default_choice}"
|
bind_choice="${bind_choice:-$default_choice}"
|
||||||
case "$bind_choice" in
|
case "$bind_choice" in
|
||||||
1|2) break ;;
|
1|2|3) break ;;
|
||||||
*) echo "Please enter 1 or 2." >&2 ;;
|
*) echo "Please enter 1, 2, or 3." >&2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$bind_choice" == "2" ]]; then
|
if [[ "$bind_choice" == "3" ]]; then
|
||||||
BIND_HOST="127.0.0.1"
|
BIND_HOST="127.0.0.1"
|
||||||
success "Binding 127.0.0.1 (this machine only)"
|
success "Binding 127.0.0.1 (this machine only)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
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
|
# Keep a custom non-loopback host from a previous install (e.g. a specific
|
||||||
# interface IP); otherwise bind all interfaces.
|
# interface IP); otherwise bind all interfaces.
|
||||||
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
|
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
|
||||||
@@ -1162,6 +1271,403 @@ choose_network_binding() {
|
|||||||
return 0
|
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://<node>.<tailnet>.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 <js-expr>: 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://<node>.<tailnet>.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)
|
# Service Setup (Linux systemd / macOS launchd)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1773,10 +2279,19 @@ main() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
if [[ "$service_ok" == "true" ]]; then
|
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 -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}# Open in browser${NC}"
|
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://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
|
||||||
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
|
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
|
||||||
else
|
else
|
||||||
@@ -1822,10 +2337,21 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}# Open in browser${NC}"
|
echo -e " ${CYAN}# Open in browser${NC}"
|
||||||
echo -e " http://localhost:3000"
|
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
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
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
|
if check_cloudflared; then
|
||||||
echo -e " ${BOLD}Remote Access (Cloudflare Tunnel):${NC}"
|
echo -e " ${BOLD}Remote Access (Cloudflare Tunnel):${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1982,6 +2508,20 @@ uninstall() {
|
|||||||
success "Removed LaunchDaemon"
|
success "Removed LaunchDaemon"
|
||||||
fi
|
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
|
# Remove symlinks
|
||||||
local symlink_dir="$HOME/.local/bin"
|
local symlink_dir="$HOME/.local/bin"
|
||||||
if [[ -L "$symlink_dir/codeman" ]]; then
|
if [[ -L "$symlink_dir/codeman" ]]; then
|
||||||
@@ -2030,6 +2570,7 @@ uninstall() {
|
|||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
update) update ;;
|
update) update ;;
|
||||||
uninstall) uninstall ;;
|
uninstall) uninstall ;;
|
||||||
|
tailscale) setup_tailscale_subcommand ;;
|
||||||
*)
|
*)
|
||||||
# Only a COMPLETED install re-runs as a quiet update. A partial one
|
# Only a COMPLETED install re-runs as a quiet update. A partial one
|
||||||
# (clone succeeded but build/menu never finished) lacks the marker and
|
# (clone succeeded but build/menu never finished) lacks the marker and
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.9.7",
|
"version": "1.9.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.9.7",
|
"version": "1.9.8",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -12333,7 +12333,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/xterm-zerolag-input": {
|
"packages/xterm-zerolag-input": {
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||||
"test:ci": "vitest run --config config/vitest.ci.config.ts",
|
"test:ci": "vitest run --config config/vitest.ci.config.ts",
|
||||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||||
|
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"scripts/postinstall.js",
|
"scripts/postinstall.js",
|
||||||
|
"scripts/fix-node-pty.mjs",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"README.md"
|
"README.md"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# xterm-zerolag-input
|
# 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-<arch>/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 `<pkg>/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 <port>`, 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
|
## 0.1.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "xterm-zerolag-input",
|
"name": "xterm-zerolag-input",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"description": "Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections",
|
"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",
|
"type": "module",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
@@ -26,8 +26,16 @@
|
|||||||
"xterm",
|
"xterm",
|
||||||
"xterm.js",
|
"xterm.js",
|
||||||
"terminal",
|
"terminal",
|
||||||
|
"web-terminal",
|
||||||
"local-echo",
|
"local-echo",
|
||||||
|
"local echo",
|
||||||
|
"mosh",
|
||||||
"input-latency",
|
"input-latency",
|
||||||
|
"latency",
|
||||||
|
"zero-lag",
|
||||||
|
"keystroke",
|
||||||
|
"ssh",
|
||||||
|
"remote-terminal",
|
||||||
"overlay",
|
"overlay",
|
||||||
"addon"
|
"addon"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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-<arch>/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
|
||||||
|
// <pkg>/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/<platform>-<arch>`, 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);
|
||||||
|
}
|
||||||
+21
-24
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, spawn } from 'child_process';
|
import { execSync, spawn } from 'child_process';
|
||||||
import { chmodSync, existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { homedir, platform } from 'os';
|
import { homedir, platform } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { createRequire } from 'module';
|
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 {
|
try {
|
||||||
const require = createRequire(import.meta.url);
|
const { fixNodePty } = await import('./fix-node-pty.mjs');
|
||||||
const ptyPath = join(require.resolve('node-pty'), '..');
|
const result = await fixNodePty({
|
||||||
const spawnHelper = join(ptyPath, 'build', 'Release', 'spawn-helper');
|
log: (line) => console.log(colors.dim(` ${line}`)),
|
||||||
if (existsSync(spawnHelper)) {
|
warn: (line) => console.log(colors.yellow(`⚠ ${line}`)),
|
||||||
chmodSync(spawnHelper, 0o755);
|
});
|
||||||
console.log(colors.green('✓ node-pty spawn-helper permissions fixed'));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-critical — only affects macOS with prebuilt binaries
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
if (result.ok) {
|
||||||
// 1c. Rebuild node-pty from source for Node.js 22+ compatibility
|
console.log(colors.green('✓ node-pty verified') + colors.dim(' (PTY spawn works)'));
|
||||||
// ----------------------------------------------------------------------------
|
} else {
|
||||||
|
|
||||||
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 {
|
|
||||||
hasWarnings = true;
|
hasWarnings = true;
|
||||||
console.log(colors.yellow('⚠ Failed to rebuild node-pty from source'));
|
console.log(colors.yellow(`⚠ node-pty is not usable: ${result.reason}`));
|
||||||
console.log(colors.dim(' You may need to run: npm rebuild node-pty --build-from-source'));
|
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
+42
-32
@@ -66,6 +66,8 @@ import {
|
|||||||
MAX_SESSION_TOKENS,
|
MAX_SESSION_TOKENS,
|
||||||
execPattern,
|
execPattern,
|
||||||
getClaudeCliVersion,
|
getClaudeCliVersion,
|
||||||
|
getClaudeBinaryPath,
|
||||||
|
spawnPtyWithHelperRepair,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
import {
|
import {
|
||||||
MAX_TERMINAL_BUFFER_SIZE,
|
MAX_TERMINAL_BUFFER_SIZE,
|
||||||
@@ -1284,15 +1286,17 @@ export class Session extends EventEmitter {
|
|||||||
const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand();
|
const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand();
|
||||||
const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName);
|
const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName);
|
||||||
try {
|
try {
|
||||||
this.ptyProcess = pty.spawn(attachCommand, attachArgs, {
|
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||||
name: 'xterm-256color',
|
pty.spawn(attachCommand, attachArgs, {
|
||||||
cols: ptyCols,
|
name: 'xterm-256color',
|
||||||
rows: ptyRows,
|
cols: ptyCols,
|
||||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
rows: ptyRows,
|
||||||
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
|
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
|
// 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) {
|
} catch (spawnErr) {
|
||||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||||
this.emit('error', `Failed to attach to mux session: ${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
|
// Pass --session-id to use the SAME ID as the Codeman session
|
||||||
// This ensures subagents can be directly matched to the correct tab
|
// This ensures subagents can be directly matched to the correct tab
|
||||||
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
|
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
|
||||||
this.ptyProcess = pty.spawn('claude', args, {
|
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||||
name: 'xterm-256color',
|
pty.spawn(getClaudeBinaryPath(), args, {
|
||||||
cols: 120,
|
name: 'xterm-256color',
|
||||||
rows: 40,
|
cols: 120,
|
||||||
cwd: this.workingDir,
|
rows: 40,
|
||||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
cwd: this.workingDir,
|
||||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||||
});
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||||
this._status = 'stopped';
|
this._status = 'stopped';
|
||||||
@@ -1949,13 +1955,15 @@ export class Session extends EventEmitter {
|
|||||||
// Fallback to direct PTY if mux is not used
|
// Fallback to direct PTY if mux is not used
|
||||||
if (!this.ptyProcess) {
|
if (!this.ptyProcess) {
|
||||||
try {
|
try {
|
||||||
this.ptyProcess = pty.spawn(shell, [], {
|
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||||
name: 'xterm-256color',
|
pty.spawn(shell, [], {
|
||||||
cols: 120,
|
name: 'xterm-256color',
|
||||||
rows: 40,
|
cols: 120,
|
||||||
cwd: this.workingDir,
|
rows: 40,
|
||||||
env: buildShellEnv(this.id),
|
cwd: this.workingDir,
|
||||||
});
|
env: buildShellEnv(this.id),
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
||||||
this._status = 'stopped';
|
this._status = 'stopped';
|
||||||
@@ -2055,14 +2063,16 @@ export class Session extends EventEmitter {
|
|||||||
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.ptyProcess = pty.spawn('claude', args, {
|
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||||
name: 'xterm-256color',
|
pty.spawn(getClaudeBinaryPath(), args, {
|
||||||
cols: 120,
|
name: 'xterm-256color',
|
||||||
rows: 40,
|
cols: 120,
|
||||||
cwd: this.workingDir,
|
rows: 40,
|
||||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
cwd: this.workingDir,
|
||||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||||
});
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||||
this.emit(
|
this.emit(
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ export function findClaudeDir(): string | null {
|
|||||||
return 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 */
|
/** Cached augmented PATH string */
|
||||||
let _augmentedPath: string | null = null;
|
let _augmentedPath: string | null = null;
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,8 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
|||||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.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 { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||||
|
|||||||
@@ -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-<arch>/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
|
||||||
|
// <pkg>/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/<platform>-<arch>`, 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<T>(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;
|
||||||
|
}
|
||||||
@@ -3456,6 +3456,10 @@ class CodemanApp {
|
|||||||
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
||||||
// so measuring mid-animation would decide the wrap on collapsed widths.
|
// so measuring mid-animation would decide the wrap on collapsed widths.
|
||||||
this._applyTabEntrances?.();
|
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,
|
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||||
|
|||||||
@@ -350,6 +350,26 @@
|
|||||||
'Show Shortcuts': '显示快捷键',
|
'Show Shortcuts': '显示快捷键',
|
||||||
'Full shortcut reference': '完整快捷键参考',
|
'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/case dialogs
|
||||||
'Session Options': '会话选项',
|
'Session Options': '会话选项',
|
||||||
'Session Name': '会话名称',
|
'Session Name': '会话名称',
|
||||||
|
|||||||
@@ -379,6 +379,12 @@
|
|||||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Overview (phone home screen, shown in place of the welcome
|
||||||
|
overlay when the "C" logo is tapped). Ships `hidden`: only
|
||||||
|
mobile-overview.js removes it, and only when the phone gate passes, so
|
||||||
|
desktop (which never loads mobile.css) can never render it. -->
|
||||||
|
<div class="mobile-overview" id="mobileOverview" hidden></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
||||||
@@ -1442,6 +1448,16 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone Section (only meaningful under 430px, hidden elsewhere by settings-ui.js) -->
|
||||||
|
<div class="settings-section-header" id="appSettingsPhoneSection">Phone</div>
|
||||||
|
<div class="settings-item" id="appSettingsMobileOverviewItem" title="On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen">
|
||||||
|
<span class="settings-item-label">Overview Home Screen</span>
|
||||||
|
<label class="switch switch-sm">
|
||||||
|
<input type="checkbox" id="appSettingsMobileOverview" checked>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab Bar Section -->
|
<!-- Tab Bar Section -->
|
||||||
<div class="settings-section-header">Tab Bar</div>
|
<div class="settings-section-header">Tab Bar</div>
|
||||||
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
|
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
|
||||||
@@ -2644,6 +2660,7 @@
|
|||||||
<script defer src="admin-ui.js"></script>
|
<script defer src="admin-ui.js"></script>
|
||||||
<script defer src="session-ui.js"></script>
|
<script defer src="session-ui.js"></script>
|
||||||
<script defer src="webview-tabs.js"></script>
|
<script defer src="webview-tabs.js"></script>
|
||||||
|
<script defer src="mobile-overview.js"></script>
|
||||||
<script defer src="entrance-animations.js"></script>
|
<script defer src="entrance-animations.js"></script>
|
||||||
<script defer src="ralph-wizard.js"></script>
|
<script defer src="ralph-wizard.js"></script>
|
||||||
<script defer src="api-client.js"></script>
|
<script defer src="api-client.js"></script>
|
||||||
|
|||||||
@@ -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<string>|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<string, object>|Array} input.sessions live sessions (this.sessions)
|
||||||
|
* @param {Array} input.cases case list (this.cases)
|
||||||
|
* @param {Array<string>} [input.sessionOrder] the user's tab order, used as the tiebreak
|
||||||
|
* @param {Map<string, Set<string>>} [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-<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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -2259,6 +2259,433 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transform: none !important;
|
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-<backend>` 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
|
/* Light-skin compatibility for mobile-only chrome. These components predate
|
||||||
|
|||||||
@@ -316,6 +316,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
select.dataset.listenerAdded = 'true';
|
select.dataset.listenerAdded = 'true';
|
||||||
}
|
}
|
||||||
this.setupQuickStartCasePicker();
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load cases:', err);
|
console.error('Failed to load cases:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
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
|
// 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
|
// 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).
|
// 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,
|
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||||
|
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||||
@@ -1616,6 +1625,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Apply CJK input visibility immediately
|
// Apply CJK input visibility immediately
|
||||||
this._updateCjkInputState();
|
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
|
// Apply keyboard bar mode
|
||||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||||
|
|
||||||
@@ -1646,6 +1659,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showSessionButton: _ssb,
|
showSessionButton: _ssb,
|
||||||
showAwayDigestButton: _adb,
|
showAwayDigestButton: _adb,
|
||||||
showCronButton: _crb,
|
showCronButton: _crb,
|
||||||
|
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||||
|
mobileOverviewEnabled: _mov,
|
||||||
...serverSettings
|
...serverSettings
|
||||||
} = settings;
|
} = settings;
|
||||||
try {
|
try {
|
||||||
@@ -1814,6 +1829,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showSessionButton: false,
|
showSessionButton: false,
|
||||||
showAwayDigestButton: false,
|
showAwayDigestButton: false,
|
||||||
showCronButton: 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
|
// Remote auto-reconnect (COD-108) — on by default
|
||||||
remoteAutoReconnect: true,
|
remoteAutoReconnect: true,
|
||||||
// Input
|
// Input
|
||||||
@@ -2269,6 +2288,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||||
|
'mobileOverviewEnabled',
|
||||||
]);
|
]);
|
||||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
// 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
|
// handheld default OFF): desktop can show it while mobile stays hidden. It
|
||||||
|
|||||||
@@ -1178,6 +1178,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showWelcome() {
|
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');
|
const overlay = document.getElementById('welcomeOverlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
overlay.classList.add('visible');
|
overlay.classList.add('visible');
|
||||||
@@ -1191,6 +1204,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
hideWelcome() {
|
hideWelcome() {
|
||||||
|
this.hideMobileOverview?.();
|
||||||
const overlay = document.getElementById('welcomeOverlay');
|
const overlay = document.getElementById('welcomeOverlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
overlay.classList.remove('visible');
|
overlay.classList.remove('visible');
|
||||||
|
|||||||
@@ -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<string, any> = {}) {
|
||||||
|
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<string, any>) {
|
||||||
|
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-<backend>` (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(/<div class="mobile-overview" id="mobileOverview" hidden><\/div>/);
|
||||||
|
expect(html).toContain('<script defer src="mobile-overview.js"></script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never gives .mobile-overview a bare display rule', () => {
|
||||||
|
// Desktop does not load mobile.css at all, so the [hidden] attribute is the
|
||||||
|
// only thing keeping the overview off desktop. A bare
|
||||||
|
// `.mobile-overview { display: … }` rule would beat the UA [hidden] rule.
|
||||||
|
const bareDisplay = /\.mobile-overview\s*\{[^}]*display\s*:/;
|
||||||
|
expect(bareDisplay.test(mobileCss)).toBe(false);
|
||||||
|
expect(mobileCss).toContain('.mobile-overview.visible {');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('styles the overview from skin tokens rather than hardcoded colors', () => {
|
||||||
|
// Skins re-point the :root tokens, so a hex literal here is a rule that
|
||||||
|
// silently stays dark on the four light skins.
|
||||||
|
const rules = mobileCss.match(/\.mobile-overview[^{}]*\{[^}]*\}/g) || [];
|
||||||
|
expect(rules.length).toBeGreaterThan(10);
|
||||||
|
const hardcoded = rules.flatMap((rule) => rule.match(/:\s*#[0-9a-f]{3,8}\b/gi) || []);
|
||||||
|
expect(hardcoded).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for the node-pty spawn-helper repair (issues #6, #204).
|
||||||
|
*
|
||||||
|
* node-pty@1.1.0 publishes `prebuilds/darwin-<arch>/spawn-helper` with mode 0644,
|
||||||
|
* so on macOS every PTY spawn dies with `posix_spawnp failed.`. These tests pin
|
||||||
|
* the two things the old fix got wrong: it looked ONLY in `build/Release` (which
|
||||||
|
* does not exist on macOS, where the prebuilt binary is used), and it never
|
||||||
|
* checked whether the repair actually worked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
isSpawnHelperFailure,
|
||||||
|
listSpawnHelpers,
|
||||||
|
repairSpawnHelperPermissions,
|
||||||
|
spawnPtyWithHelperRepair,
|
||||||
|
resetSpawnHelperRepairState,
|
||||||
|
SPAWN_HELPER_FIX_HINT,
|
||||||
|
} from '../src/utils/node-pty-repair.js';
|
||||||
|
|
||||||
|
/** Builds a fake node-pty tree; each entry is a directory that gets a spawn-helper. */
|
||||||
|
function makeFakePtyDir(helpers: Array<{ dir: string; mode: number }>): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-node-pty-'));
|
||||||
|
for (const { dir, mode } of helpers) {
|
||||||
|
const full = join(root, dir);
|
||||||
|
mkdirSync(full, { recursive: true });
|
||||||
|
const helper = join(full, 'spawn-helper');
|
||||||
|
writeFileSync(helper, '#!/bin/sh\nexit 0\n');
|
||||||
|
chmodSync(helper, mode);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeOf(path: string): number {
|
||||||
|
return statSync(path).mode & 0o777;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('node-pty spawn-helper repair', () => {
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => resetSpawnHelperRepairState());
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(helpers: Array<{ dir: string; mode: number }>): string {
|
||||||
|
const root = makeFakePtyDir(helpers);
|
||||||
|
created.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isSpawnHelperFailure', () => {
|
||||||
|
it('matches the native error node-pty throws on macOS', () => {
|
||||||
|
expect(isSpawnHelperFailure(new Error('posix_spawnp failed.'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches errors that name the helper directly', () => {
|
||||||
|
expect(isSpawnHelperFailure(new Error('ENOENT: no such file, spawn-helper'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unrelated spawn failures', () => {
|
||||||
|
expect(isSpawnHelperFailure(new Error('cwd does not exist'))).toBe(false);
|
||||||
|
expect(isSpawnHelperFailure(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSpawnHelpers', () => {
|
||||||
|
it('finds the prebuilt helper, which is the ONLY one that exists on macOS', () => {
|
||||||
|
// A stock macOS install has no build/ directory at all: node-pty ships a
|
||||||
|
// darwin prebuild, so node-gyp never runs.
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||||
|
expect(listSpawnHelpers(root)).toEqual([join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds helpers across build/Release, build/Debug and every prebuilds arch', () => {
|
||||||
|
const root = fixture([
|
||||||
|
{ dir: 'build/Release', mode: 0o755 },
|
||||||
|
{ dir: 'build/Debug', mode: 0o644 },
|
||||||
|
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
|
||||||
|
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
|
||||||
|
]);
|
||||||
|
expect(listSpawnHelpers(root).sort()).toEqual(
|
||||||
|
[
|
||||||
|
join(root, 'build', 'Release', 'spawn-helper'),
|
||||||
|
join(root, 'build', 'Debug', 'spawn-helper'),
|
||||||
|
join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper'),
|
||||||
|
join(root, 'prebuilds', 'darwin-x64', 'spawn-helper'),
|
||||||
|
].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for a Linux install, which has no spawn-helper at all', () => {
|
||||||
|
const root = fixture([]);
|
||||||
|
mkdirSync(join(root, 'build', 'Release'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'build', 'Release', 'pty.node'), 'stub');
|
||||||
|
expect(listSpawnHelpers(root)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairSpawnHelperPermissions', () => {
|
||||||
|
it('adds the execute bit to the 0644 prebuilt helper', () => {
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||||
|
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||||
|
|
||||||
|
const repaired = repairSpawnHelperPermissions(root);
|
||||||
|
|
||||||
|
expect(repaired).toEqual([helper]);
|
||||||
|
expect(modeOf(helper) & 0o111).toBe(0o111);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op on an already-executable helper', () => {
|
||||||
|
const root = fixture([{ dir: 'build/Release', mode: 0o755 }]);
|
||||||
|
expect(repairSpawnHelperPermissions(root)).toEqual([]);
|
||||||
|
expect(modeOf(join(root, 'build', 'Release', 'spawn-helper'))).toBe(0o755);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs every copy, not just the first one found', () => {
|
||||||
|
const root = fixture([
|
||||||
|
{ dir: 'prebuilds/darwin-arm64', mode: 0o644 },
|
||||||
|
{ dir: 'prebuilds/darwin-x64', mode: 0o644 },
|
||||||
|
]);
|
||||||
|
expect(repairSpawnHelperPermissions(root)).toHaveLength(2);
|
||||||
|
for (const arch of ['darwin-arm64', 'darwin-x64']) {
|
||||||
|
expect(modeOf(join(root, 'prebuilds', arch, 'spawn-helper')) & 0o111).toBe(0o111);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the non-execute permission bits it was given', () => {
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o640 }]);
|
||||||
|
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||||
|
repairSpawnHelperPermissions(root);
|
||||||
|
expect(modeOf(helper)).toBe(0o755 | 0o640);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when node-pty has no helper to repair', () => {
|
||||||
|
expect(repairSpawnHelperPermissions(fixture([]))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spawnPtyWithHelperRepair', () => {
|
||||||
|
it('passes the spawn result straight through when nothing is wrong', () => {
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o755 }]);
|
||||||
|
expect(spawnPtyWithHelperRepair(() => 'pty', root)).toBe('pty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs and retries once after a posix_spawnp failure', () => {
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||||
|
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const result = spawnPtyWithHelperRepair(() => {
|
||||||
|
attempts++;
|
||||||
|
// Mirror the real failure: node-pty only throws while the helper is 0644.
|
||||||
|
if ((modeOf(helper) & 0o111) !== 0o111) throw new Error('posix_spawnp failed.');
|
||||||
|
return 'pty';
|
||||||
|
}, root);
|
||||||
|
|
||||||
|
expect(result).toBe('pty');
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
expect(modeOf(helper) & 0o111).toBe(0o111);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows unrelated errors untouched, without chmodding anything', () => {
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||||
|
const helper = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper');
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
spawnPtyWithHelperRepair(() => {
|
||||||
|
throw new Error('cwd does not exist');
|
||||||
|
}, root)
|
||||||
|
).toThrow('cwd does not exist');
|
||||||
|
|
||||||
|
expect(modeOf(helper)).toBe(0o644);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the manual fix command when the retry still fails', () => {
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
spawnPtyWithHelperRepair(() => {
|
||||||
|
throw new Error('posix_spawnp failed.');
|
||||||
|
}, root)
|
||||||
|
).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the fix command when there is no helper to repair', () => {
|
||||||
|
const root = fixture([]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
spawnPtyWithHelperRepair(() => {
|
||||||
|
throw new Error('posix_spawnp failed.');
|
||||||
|
}, root)
|
||||||
|
).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not chmod-storm: only the first failure triggers a repair attempt', () => {
|
||||||
|
const root = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||||
|
const boom = () => {
|
||||||
|
throw new Error('posix_spawnp failed.');
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => spawnPtyWithHelperRepair(boom, root)).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||||
|
|
||||||
|
// Second call: repair already attempted, so it fails fast with the hint and
|
||||||
|
// never re-walks the tree.
|
||||||
|
const untouched = fixture([{ dir: 'prebuilds/darwin-arm64', mode: 0o644 }]);
|
||||||
|
expect(() => spawnPtyWithHelperRepair(boom, untouched)).toThrow(SPAWN_HELPER_FIX_HINT);
|
||||||
|
expect(modeOf(join(untouched, 'prebuilds', 'darwin-arm64', 'spawn-helper'))).toBe(0o644);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user