mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55bc28a8e |
@@ -10,7 +10,7 @@
|
|||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"source": "./plugins/codeman",
|
"source": "./plugins/codeman",
|
||||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.33.3",
|
"version": "1.33.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -28,15 +28,12 @@ The frontend is plain JS served from `src/web/public/` with no bundler in dev: e
|
|||||||
CI runs all of these, so save yourself a round trip:
|
CI runs all of these, so save yourself a round trip:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck # tsc --noEmit, strict mode
|
npm run typecheck # tsc --noEmit, strict mode
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run format:check
|
npm run format:check
|
||||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||||
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -34,13 +34,6 @@ jobs:
|
|||||||
- name: Frontend JS syntax check
|
- name: Frontend JS syntax check
|
||||||
run: npm run check:frontend-syntax
|
run: npm run check:frontend-syntax
|
||||||
|
|
||||||
# Asks `vitest list` what CI would actually collect, rather than matching
|
|
||||||
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
|
|
||||||
# (config/test-suites.ts) passes locally and dies in the test job with
|
|
||||||
# "browserType.launch: Executable doesn't exist".
|
|
||||||
- name: Browser-test exclusion check
|
|
||||||
run: npm run check:browser-excludes
|
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: npm run format:check
|
run: npm run format:check
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,5 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
## 1.33.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- f776ad8: ### Thanks
|
|
||||||
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
|
|
||||||
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
|
|
||||||
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
|
|
||||||
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
|
|
||||||
|
|
||||||
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
|
|
||||||
|
|
||||||
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
|
|
||||||
|
|
||||||
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
|
|
||||||
|
|
||||||
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
|
|
||||||
|
|
||||||
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
|
|
||||||
|
|
||||||
## 1.33.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- e439cf0: ### Thanks
|
|
||||||
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
|
|
||||||
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
|
|
||||||
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
|
|
||||||
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
|
|
||||||
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
|
|
||||||
|
|
||||||
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
|
|
||||||
|
|
||||||
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
|
|
||||||
|
|
||||||
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
|
|
||||||
|
|
||||||
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
|
|
||||||
|
|
||||||
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
|
|
||||||
|
|
||||||
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
|
|
||||||
|
|
||||||
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
|
|
||||||
|
|
||||||
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
|
|
||||||
|
|
||||||
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
|
|
||||||
|
|
||||||
**Contributor tooling (#500).** `npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
|
|
||||||
|
|
||||||
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
|
|
||||||
|
|
||||||
## 1.33.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- ### Thanks
|
|
||||||
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
|
|
||||||
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
|
|
||||||
|
|
||||||
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
|
||||||
|
|
||||||
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
|
|
||||||
|
|
||||||
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
|
|
||||||
|
|
||||||
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
|
|
||||||
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
|
|
||||||
|
|
||||||
Thanks @opticon454.
|
|
||||||
|
|
||||||
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
|
|
||||||
|
|
||||||
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
|
|
||||||
|
|
||||||
Thanks @opticon454 (#487, #485).
|
|
||||||
|
|
||||||
## 1.33.0
|
## 1.33.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
|
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
|
||||||
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
|
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
|
||||||
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
|
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
|
||||||
- **A pre-push failure in a file you did not touch is another session's WIP.** Push with `CODEMAN_SKIP_PREPUSH=1 git push` and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)
|
|
||||||
|
|
||||||
## CRITICAL: Always Test Before Deploying
|
## CRITICAL: Always Test Before Deploying
|
||||||
|
|
||||||
@@ -78,7 +77,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.33.3 (must match `package.json`)
|
**Version**: 1.33.0 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -113,8 +112,6 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
|
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
|
||||||
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
||||||
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
|
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
|
||||||
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
|
|
||||||
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
|
|
||||||
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
|
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
|
||||||
| Production start | `npm run start` |
|
| Production start | `npm run start` |
|
||||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||||
@@ -123,7 +120,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
||||||
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
||||||
|
|
||||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
||||||
|
|
||||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||||
|
|
||||||
@@ -131,7 +128,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
## Common Gotchas
|
## Common Gotchas
|
||||||
|
|
||||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
|
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted
|
||||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||||
@@ -206,11 +203,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
||||||
|
|
||||||
⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
|
||||||
|
|
||||||
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
|
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
|
||||||
|
|
||||||
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
|
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
|
||||||
|
|
||||||
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
|
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
|
||||||
|
|
||||||
@@ -232,9 +227,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
|
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
|
||||||
|
|
||||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||||
|
|
||||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||||
|
|
||||||
@@ -270,7 +265,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||||
|
|
||||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and Shell loads the rest only via **Load full history**, never on ordinary scroll. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||||
|
|
||||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||||
|
|
||||||
@@ -280,7 +275,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||||
|
|
||||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY**; ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||||
|
|
||||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||||
@@ -295,8 +290,6 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||||
|
|
||||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: only an IN-WORKSPACE `.md` path clicked in the TERMINAL keeps the tail viewer (live follow); the Files panel, chat paths, out-of-workspace terminal paths and attachment cards all reach `openFilePreview()` and render it. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
|
||||||
|
|
||||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||||
|
|
||||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
|
||||||
@@ -335,7 +328,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||||
|
|
||||||
**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 into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [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 into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [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.
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -23,10 +23,6 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -15,12 +15,6 @@ TZ=Australia/Perth
|
|||||||
# this value rebuilds the image with a matching account.
|
# this value rebuilds the image with a matching account.
|
||||||
CODEMAN_RUNTIME_USER=codeman
|
CODEMAN_RUNTIME_USER=codeman
|
||||||
|
|
||||||
# Optional Git identity for commits made by Codeman and Docker-case agents. These values
|
|
||||||
# are written to each image's system Git configuration when it is rebuilt, so
|
|
||||||
# deployments can configure a consistent default. Set both values together.
|
|
||||||
# GIT_USER_NAME=
|
|
||||||
# GIT_USER_EMAIL=
|
|
||||||
|
|
||||||
# Required. Persistent Codeman application data, CLI credentials, and session
|
# Required. Persistent Codeman application data, CLI credentials, and session
|
||||||
# state are stored here on the host and mounted at the runtime account's home
|
# state are stored here on the host and mounted at the runtime account's home
|
||||||
# directory in the container.
|
# directory in the container.
|
||||||
|
|||||||
@@ -67,27 +67,6 @@ two volumes are removed, by name within this Compose project; any volume a
|
|||||||
`docker-compose.override.yml` adds is left alone, and application data and
|
`docker-compose.override.yml` adds is left alone, and application data and
|
||||||
case workspaces are host bind mounts, never touched either way.
|
case workspaces are host bind mounts, never touched either way.
|
||||||
|
|
||||||
## Git commit identity
|
|
||||||
|
|
||||||
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
GIT_USER_NAME='Your Name'
|
|
||||||
GIT_USER_EMAIL='you@example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
Compose passes the values to the Codeman server build, and to the server process
|
|
||||||
when it builds Docker-case agent images. Both images write the pair to Git's
|
|
||||||
system configuration during their build, so commits retain the same identity
|
|
||||||
after a container or agent image is recreated. Set both values together; an
|
|
||||||
image build with only one value fails rather than using a partial identity. An
|
|
||||||
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
|
|
||||||
the server image's system-level default.
|
|
||||||
|
|
||||||
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
|
|
||||||
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
|
|
||||||
server container, then recreate any Docker cases that should use it.
|
|
||||||
|
|
||||||
## Private repositories (GitHub and Azure DevOps)
|
## Private repositories (GitHub and Azure DevOps)
|
||||||
|
|
||||||
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
|
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ FROM node:22-bookworm-slim
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
git \
|
git \
|
||||||
libsecret-1-0 \
|
|
||||||
tmux \
|
tmux \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
curl \
|
curl \
|
||||||
@@ -127,10 +126,6 @@ RUN set -eux; \
|
|||||||
# A different order is a different RUN string, which is a different layer hash and
|
# A different order is a different RUN string, which is a different layer hash and
|
||||||
# so a needless cache miss between a bare `docker build` and a scripted one.
|
# so a needless cache miss between a bare `docker build` and a scripted one.
|
||||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
||||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
|
||||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
|
||||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
|
||||||
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
@@ -254,21 +249,6 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
|||||||
&& chgrp -R 0 /home/agent \
|
&& chgrp -R 0 /home/agent \
|
||||||
&& chmod -R g=u /home/agent
|
&& chmod -R g=u /home/agent
|
||||||
|
|
||||||
# Docker cases have a fresh, container-owned home directory. Declare the
|
|
||||||
# optional identity here so changing it invalidates only this final layer, then
|
|
||||||
# configure Git's system defaults. A user-level config still takes precedence.
|
|
||||||
ARG GIT_USER_EMAIL=
|
|
||||||
ARG GIT_USER_NAME=
|
|
||||||
RUN set -eux; \
|
|
||||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
|
||||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
|
||||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
git config --system user.name "${GIT_USER_NAME}"; \
|
|
||||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
USER agent
|
USER agent
|
||||||
WORKDIR /home/agent
|
WORKDIR /home/agent
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ services:
|
|||||||
dockerfile: docker/server.Dockerfile
|
dockerfile: docker/server.Dockerfile
|
||||||
args:
|
args:
|
||||||
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
|
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
|
||||||
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
|
||||||
GIT_USER_NAME: ${GIT_USER_NAME:-}
|
|
||||||
PGID: ${PGID:-1000}
|
PGID: ${PGID:-1000}
|
||||||
PUID: ${PUID:-1000}
|
PUID: ${PUID:-1000}
|
||||||
image: ${CODEMAN_IMAGE}
|
image: ${CODEMAN_IMAGE}
|
||||||
@@ -34,10 +32,6 @@ services:
|
|||||||
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
||||||
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
||||||
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
||||||
# Passed through only so Codeman can use the same identity when it builds
|
|
||||||
# the Docker-case agent image.
|
|
||||||
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
|
||||||
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: ${GIT_USER_NAME:-}
|
|
||||||
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
||||||
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
||||||
# defaults to empty rather than requiring a line in every .env.
|
# defaults to empty rather than requiring a line in every .env.
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ RUN apt-get update \
|
|||||||
curl \
|
curl \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
libsecret-1-0 \
|
|
||||||
make \
|
make \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
procps \
|
procps \
|
||||||
@@ -213,28 +212,13 @@ RUN set -eux; \
|
|||||||
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
||||||
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
||||||
# system directories for the root part of the start.
|
# system directories for the root part of the start.
|
||||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
|
||||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
|
||||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
|
||||||
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
||||||
ENV PATH=$PATH:/opt/codeman-cli/bin
|
ENV PATH=$PATH:/opt/codeman-cli/bin
|
||||||
# CLIs installed at runtime (Settings -> CLIs, npm redirected to ~/.local by installEnv()) live on the
|
|
||||||
# persistent home mount, so they survive a container recreate. Appended for the same reason as above.
|
|
||||||
ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin
|
|
||||||
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
|
|
||||||
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
|
|
||||||
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
|
|
||||||
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
|
|
||||||
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
|
|
||||||
# already carries it for the same reason (#352). It lives in the same
|
|
||||||
# runtime-writable prefix as the CLIs, so a session can update it in place.
|
|
||||||
RUN npm install --global \
|
RUN npm install --global \
|
||||||
@anthropic-ai/claude-code@2.1.258 \
|
@anthropic-ai/claude-code@2.1.258 \
|
||||||
@google/gemini-cli@0.58.0 \
|
@google/gemini-cli@0.58.0 \
|
||||||
@openai/codex@0.152.1 \
|
@openai/codex@0.152.1 \
|
||||||
opencode-ai@1.18.26 \
|
opencode-ai@1.18.26 \
|
||||||
pnpm@12.6.0 \
|
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||||
@@ -302,21 +286,6 @@ EXPOSE 3000
|
|||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
# Declare the optional identity immediately before configuring it so a change
|
|
||||||
# invalidates only this final layer. This is declarative setup: a persisted
|
|
||||||
# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values.
|
|
||||||
ARG GIT_USER_EMAIL=
|
|
||||||
ARG GIT_USER_NAME=
|
|
||||||
RUN set -eux; \
|
|
||||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
|
||||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
|
||||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
git config --system user.name "${GIT_USER_NAME}"; \
|
|
||||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|
||||||
CMD ["node", "dist/index.js", "web"]
|
CMD ["node", "dist/index.js", "web"]
|
||||||
|
|||||||
@@ -311,15 +311,6 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
|
|||||||
turn that never started. Verified live; this is the most common silent failure on
|
turn that never started. Verified live; this is the most common silent failure on
|
||||||
this endpoint.
|
this endpoint.
|
||||||
|
|
||||||
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
|
|
||||||
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
|
|
||||||
a separate key, and the server re-presses Enter while the prompt is still visibly
|
|
||||||
sitting on the composer. Written straight into the pane in one piece, a prompt of
|
|
||||||
about a hundred characters or more is taken as a paste by Claude Code, its `\r`
|
|
||||||
becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other
|
|
||||||
input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps
|
|
||||||
the raw write, and an explicit `"useMux": false` forces it.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+3
-19
@@ -46,9 +46,8 @@ interface CliEntry {
|
|||||||
launch: CliLaunch; // the structured argv template
|
launch: CliLaunch; // the structured argv template
|
||||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
|
||||||
// — how this CLI's pane shows work, work it started in the background, and a turn
|
// this CLI's pane shows work, and how it shows work it started in the background
|
||||||
// that ended waiting for workers it will resume from
|
|
||||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -57,7 +56,7 @@ interface CliEntry {
|
|||||||
|
|
||||||
### Regexes that come from config
|
### Regexes that come from config
|
||||||
|
|
||||||
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||||
|
|
||||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||||
|
|
||||||
@@ -67,9 +66,6 @@ agents` while a monitor, a backgrounded shell or a cloud session is live. Codema
|
|||||||
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
||||||
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||||
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
|
|
||||||
to comment on a page the agent published, so Claude's pattern refuses any footer that
|
|
||||||
carries it, and the idle alert goes out as usual.
|
|
||||||
|
|
||||||
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||||
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||||
@@ -80,18 +76,6 @@ entry declares `watchingLines: 3` and matches that row end to end. Both were mea
|
|||||||
against live panes rather than read out of a binary, which is the standard for adding a
|
against live panes rather than read out of a binary, which is the standard for adding a
|
||||||
third.
|
third.
|
||||||
|
|
||||||
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
|
|
||||||
to wait for workers the CLI will resume from by itself. When background agents or an
|
|
||||||
ultracode workflow are still running at turn end, Claude closes the turn with
|
|
||||||
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
|
|
||||||
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
|
|
||||||
the words are still on screen after the workers report back and the follow-up turn ends.
|
|
||||||
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
|
|
||||||
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
|
|
||||||
tests only the first row that starts in column 0, which is the newest transcript row. Claude
|
|
||||||
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
|
|
||||||
agent from holding its own session busy.
|
|
||||||
|
|
||||||
That label is the one value in the registry that an AGENT can influence, because it comes off
|
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||||
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||||
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
|
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
|
|||||||
surfaces that same line as the install error. `npm install -g pnpm` (or
|
surfaces that same line as the install error. `npm install -g pnpm` (or
|
||||||
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
||||||
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
||||||
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
|
alongside `dsh`.
|
||||||
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
|
|
||||||
button works there too.
|
|
||||||
|
|
||||||
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
||||||
margin the most used community TUI, it is MIT, and it implements the status
|
margin the most used community TUI, it is MIT, and it implements the status
|
||||||
|
|||||||
@@ -83,8 +83,6 @@ Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.jso
|
|||||||
|
|
||||||
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
||||||
|
|
||||||
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
|
|
||||||
|
|
||||||
## Quickest path: one-click "Run in Docker"
|
## Quickest path: one-click "Run in Docker"
|
||||||
|
|
||||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ For the Compose configuration, environment settings, storage migration, and macv
|
|||||||
|
|
||||||
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
||||||
|
|
||||||
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
|
|
||||||
|
|
||||||
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
|
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|||||||
@@ -338,42 +338,6 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
|
|||||||
codeman status | list | attach <path> codeman doctor
|
codeman status | list | attach <path> codeman doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
### Opening a session from your own page
|
|
||||||
|
|
||||||
To send someone from your page to one session, link to the dashboard with the
|
|
||||||
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
|
|
||||||
dashboard selects that tab when it loads. It also removes the fragment from its
|
|
||||||
own URL, so a later link to the same session still counts as a change.
|
|
||||||
|
|
||||||
Keep reusing one named window to make later links fast:
|
|
||||||
|
|
||||||
```js
|
|
||||||
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
|
|
||||||
```
|
|
||||||
|
|
||||||
When that window already shows the dashboard, only the fragment differs. The
|
|
||||||
browser therefore keeps the page loaded, and the dashboard switches tabs without
|
|
||||||
reloading it. A session the window has shown before appears at once. A session
|
|
||||||
your page has only just created may not be listed yet, so the dashboard waits
|
|
||||||
for its `session:created` event and selects it then. That wait lasts at most 30
|
|
||||||
seconds: a link whose session never appears (a closed session, a typo, or in
|
|
||||||
multi-user mode another user's session) is dropped with a "Session not found"
|
|
||||||
notice. Clicking another tab, going Home or opening a web tab also ends the
|
|
||||||
wait, so a session that turns up later never takes the screen from the person.
|
|
||||||
|
|
||||||
When your page holds the window reference (`const win = window.open(...)`),
|
|
||||||
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
|
|
||||||
without a reload, but adds no history entry, so Back in the dashboard window
|
|
||||||
does not turn into a silent no-op.
|
|
||||||
|
|
||||||
Following a link does not count as someone looking at the session, so it
|
|
||||||
leaves the session's idle alert in place. The alert clears when the person
|
|
||||||
clicks the tab or types into the session. A link to a session that is popped
|
|
||||||
out into its own window asks that window to come forward, as clicking its tab does.
|
|
||||||
|
|
||||||
A link to `/session/<id>` opens a page showing that session alone, and that
|
|
||||||
page loads from scratch for every link.
|
|
||||||
|
|
||||||
## Seam 4: Hooks
|
## Seam 4: Hooks
|
||||||
|
|
||||||
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
|||||||
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
||||||
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
||||||
is kept working for the bundled UI.
|
is kept working for the bundled UI.
|
||||||
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
|
|
||||||
`#session=<id>` fragment selects that session if this client can see it. The
|
|
||||||
fragment name and that meaning are stable; see
|
|
||||||
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
|
|
||||||
|
|
||||||
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
||||||
|
|
||||||
|
|||||||
@@ -42,17 +42,10 @@ npm run typecheck
|
|||||||
npm run lint
|
npm run lint
|
||||||
npm run format:check
|
npm run format:check
|
||||||
npm run check:frontend-syntax
|
npm run check:frontend-syntax
|
||||||
npm run check:browser-excludes
|
|
||||||
npm test -- test/<file>.test.ts # one file, the normal way
|
npm test -- test/<file>.test.ts # one file, the normal way
|
||||||
npm run test:ci # the full CI sweep
|
npm run test:ci # the full CI sweep
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
|
||||||
machine-dependent) and blocks a push that would fail them. It skips itself when you push
|
|
||||||
something other than the checked-out HEAD, or when the tree has uncommitted changes the
|
|
||||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
|
||||||
`pre-push` hook of your own is never overwritten.
|
|
||||||
|
|
||||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
||||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
||||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
fail on a normal machine. `test:ci` is the honest "run everything".
|
||||||
|
|||||||
@@ -147,12 +147,6 @@ in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
|
|||||||
`docker/README.md` ("Private repositories") has the details and the matching switches for
|
`docker/README.md` ("Private repositories") has the details and the matching switches for
|
||||||
the server image.
|
the server image.
|
||||||
|
|
||||||
To give agents a fixed Git commit identity, set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and
|
|
||||||
`CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together in that same environment (in the Docker
|
|
||||||
deployment, set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` instead, which feeds
|
|
||||||
both images). An existing `codeman/agent:base` only picks it up after a `--no-cache` rebuild
|
|
||||||
and recreated case containers; `docker/README.md` ("Git commit identity") has the details.
|
|
||||||
|
|
||||||
## Isolation
|
## Isolation
|
||||||
|
|
||||||
Every container runs hardened by default:
|
Every container runs hardened by default:
|
||||||
|
|||||||
@@ -127,10 +127,6 @@ plain prose is not a dialog, so an agent that starts a monitor and then writes "
|
|||||||
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
||||||
been quiet longer than the work it is waiting for should take.
|
been quiet longer than the work it is waiting for should take.
|
||||||
|
|
||||||
An agent waiting for your comments on an artifact it published never counts as watching.
|
|
||||||
Claude shows that as "1 Artifact comment monitor", but the agent hears nothing until you
|
|
||||||
comment, so the session alerts you like any other quiet session.
|
|
||||||
|
|
||||||
## The phone overview
|
## The phone overview
|
||||||
|
|
||||||
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
||||||
|
|||||||
@@ -151,13 +151,10 @@ Worth knowing:
|
|||||||
|
|
||||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||||
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
|
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
||||||
history; press **Load full history** to pull the rest explicitly. Automatic output
|
and automatic output recovery stay within the bounded browser buffer.
|
||||||
recovery stays within the bounded browser buffer.
|
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
||||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
||||||
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
|
||||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
|
||||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
|
||||||
always local scrollback. Other CLIs scroll locally.
|
always local scrollback. Other CLIs scroll locally.
|
||||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||||
`Ctrl+Shift+C` always copies.
|
`Ctrl+Shift+C` always copies.
|
||||||
|
|||||||
@@ -164,11 +164,8 @@ Scrollback behaviour depends on the CLI, and Codeman adjusts what it strips per
|
|||||||
Things to try:
|
Things to try:
|
||||||
|
|
||||||
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
|
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
|
||||||
- On Claude sessions running fullscreen (recent CLI with mouse tracking on), the wheel is
|
- On Claude sessions with a recent CLI, the wheel is forwarded into Claude's own transcript,
|
||||||
forwarded into Claude's own transcript, so it scrolls the conversation rather than the
|
so it scrolls the conversation rather than the terminal buffer. That is intended.
|
||||||
terminal buffer. That is intended. Claude's default inline view scrolls locally; turn
|
|
||||||
fullscreen on with `CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in
|
|
||||||
`~/.claude/settings.json`.
|
|
||||||
- Scrolling to the very top pulls the full tmux scrollback again on demand.
|
- Scrolling to the very top pulls the full tmux scrollback again on demand.
|
||||||
|
|
||||||
### The wheel does nothing in a Codex session
|
### The wheel does nothing in a Codex session
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ It renders what it can:
|
|||||||
|
|
||||||
| Kind | Behaviour |
|
| Kind | Behaviour |
|
||||||
| ------------------------ | ------------------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------------------- |
|
||||||
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
|
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
|
||||||
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
|
||||||
| Images | Inline. |
|
| Images | Inline. |
|
||||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||||
@@ -86,9 +85,8 @@ File paths in a session are links. That works in two places:
|
|||||||
render as underlined monospace links.
|
render as underlined monospace links.
|
||||||
|
|
||||||
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
||||||
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
|
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
|
||||||
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
|
the tail viewer instead, which follows a file that is still being written.
|
||||||
viewer instead, which follows a file that is still being written.
|
|
||||||
|
|
||||||
Paths **outside** the session's workspace work too, which matters because that is where most
|
Paths **outside** the session's workspace work too, which matters because that is where most
|
||||||
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
|
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.33.3",
|
"version": "1.33.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.33.3",
|
"version": "1.33.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.33.3",
|
"version": "1.33.0",
|
||||||
"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",
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
||||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||||
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
|
||||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||||
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
||||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||||
"version": "1.33.3",
|
"version": "1.33.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Browser-test exclusion check.
|
|
||||||
*
|
|
||||||
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
|
|
||||||
* clean checkout) have no chromium, so such a file dies with
|
|
||||||
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
|
|
||||||
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
|
|
||||||
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
|
|
||||||
* BY HAND, and a new browser test simply does not appear in it unless someone
|
|
||||||
* remembers. The omission is invisible on a developer machine that has run
|
|
||||||
* `npx playwright install`, where the test passes, and only shows up on a clean
|
|
||||||
* runner.
|
|
||||||
*
|
|
||||||
* Two deliberate design choices:
|
|
||||||
*
|
|
||||||
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
|
|
||||||
* would miss the browser tests that predate that convention
|
|
||||||
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
|
|
||||||
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
|
|
||||||
* file dangerous is importing a browser driver, so that is what is tested.
|
|
||||||
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
|
|
||||||
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
|
|
||||||
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
|
|
||||||
*
|
|
||||||
* 2. **The exclusion side is answered by vitest itself**, via
|
|
||||||
* `vitest list --filesOnly`, rather than by re-implementing glob matching
|
|
||||||
* against the config's `exclude` array. Patterns there include `test/mobile/**`
|
|
||||||
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
|
|
||||||
* edge case would report a gap that does not exist, or miss one that does.
|
|
||||||
* Asking the real resolver cannot drift from the real behaviour.
|
|
||||||
*
|
|
||||||
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
|
|
||||||
* check itself only runs when this file is executed directly.
|
|
||||||
*/
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { join, dirname, relative, sep, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
|
|
||||||
const SUITES_FILE = join('config', 'test-suites.ts');
|
|
||||||
|
|
||||||
/** Importing any one of these means the test needs a real browser binary. */
|
|
||||||
const BROWSER_DRIVER =
|
|
||||||
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
|
|
||||||
|
|
||||||
/** @param {string} source */
|
|
||||||
export function importsBrowserDriver(source) {
|
|
||||||
return BROWSER_DRIVER.test(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} dir @returns {string[]} */
|
|
||||||
function walk(dir) {
|
|
||||||
const out = [];
|
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const path = join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) out.push(...walk(path));
|
|
||||||
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `*.test.ts` under `<root>/test`, as sorted repo-relative POSIX paths (the form
|
|
||||||
* `vitest list` prints).
|
|
||||||
*
|
|
||||||
* @param {string} root
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function findTestFiles(root) {
|
|
||||||
return walk(join(root, 'test'))
|
|
||||||
.map((file) => relative(root, file).split(sep).join('/'))
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subset of {@link findTestFiles} that imports a browser driver.
|
|
||||||
*
|
|
||||||
* @param {string} root
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
export function findBrowserTests(root) {
|
|
||||||
return findTestFiles(root).filter((file) => importsBrowserDriver(readFileSync(join(root, file), 'utf8')));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
|
|
||||||
* blank or decorative lines are ignored rather than assuming the format is pristine.
|
|
||||||
*
|
|
||||||
* @param {string} output
|
|
||||||
* @returns {Set<string>}
|
|
||||||
*/
|
|
||||||
export function parseVitestFileList(output) {
|
|
||||||
return new Set(
|
|
||||||
output
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.endsWith('.test.ts'))
|
|
||||||
.map((line) => line.replace(/^\.\//, ''))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the `vitest list` paths and the walked tree name at least one file in common.
|
|
||||||
* False means the two sides are not speaking the same path format (absolute paths, backslashes
|
|
||||||
* or a new prefix after a vitest upgrade), and then {@link findLeaks} would find nothing
|
|
||||||
* against a perfectly non-empty listing.
|
|
||||||
*
|
|
||||||
* @param {Set<string>} ciFiles
|
|
||||||
* @param {string[]} testFiles
|
|
||||||
*/
|
|
||||||
export function listingMatchesTree(ciFiles, testFiles) {
|
|
||||||
return testFiles.some((file) => ciFiles.has(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string[]} browserTests
|
|
||||||
* @param {Set<string>} ciFiles
|
|
||||||
* @returns {string[]} browser-driven files that the CI config would still collect
|
|
||||||
*/
|
|
||||||
export function findLeaks(browserTests, ciFiles) {
|
|
||||||
return browserTests.filter((file) => ciFiles.has(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const testFiles = findTestFiles(ROOT);
|
|
||||||
const browserTests = findBrowserTests(ROOT);
|
|
||||||
|
|
||||||
let collected;
|
|
||||||
try {
|
|
||||||
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
|
|
||||||
cwd: ROOT,
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('✗ could not enumerate the CI test set via `vitest list`.');
|
|
||||||
console.error(err.stderr ? err.stderr.toString() : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ciFiles = parseVitestFileList(collected);
|
|
||||||
if (ciFiles.size === 0) {
|
|
||||||
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
|
|
||||||
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
|
|
||||||
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
|
|
||||||
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
|
|
||||||
if (!listingMatchesTree(ciFiles, testFiles)) {
|
|
||||||
const sample = [...ciFiles].slice(0, 3).join(', ');
|
|
||||||
console.error(
|
|
||||||
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const leaked = findLeaks(browserTests, ciFiles);
|
|
||||||
|
|
||||||
if (leaked.length > 0) {
|
|
||||||
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
|
|
||||||
for (const file of leaked) console.error(` ${file}`);
|
|
||||||
console.error(`
|
|
||||||
These import a browser driver, so on a runner with no chromium they fail with
|
|
||||||
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
|
|
||||||
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
|
|
||||||
it, and \`npm run test:browser\` its includes).
|
|
||||||
|
|
||||||
They may well pass on this machine; that is the trap. To reproduce a clean
|
|
||||||
runner locally:
|
|
||||||
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
||||||
main();
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
|
|
||||||
* pinned by test/git-hooks.test.ts.
|
|
||||||
*
|
|
||||||
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
|
|
||||||
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
|
|
||||||
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
|
|
||||||
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
|
|
||||||
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
|
|
||||||
*
|
|
||||||
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
|
|
||||||
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
|
|
||||||
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
|
|
||||||
* within a day. The checks below mirror the static CI job.
|
|
||||||
*
|
|
||||||
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
|
|
||||||
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
|
|
||||||
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
|
|
||||||
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
|
|
||||||
* the second case is usually another session's WIP, which must not block this push.
|
|
||||||
*
|
|
||||||
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
|
|
||||||
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
|
|
||||||
* survive `npm install`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ownership marker. ⚠️ Never bump the version suffix: ownership is matched on this exact
|
|
||||||
* string, so a `v2` would read every installed `v1` hook as foreign and never refresh it.
|
|
||||||
* A changed body still reaches installed hooks, because the refresh compares the whole file.
|
|
||||||
*/
|
|
||||||
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
|
|
||||||
* is the argument list for `npm run`, and each is a step of the static job in
|
|
||||||
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
|
|
||||||
*/
|
|
||||||
export const PRE_PUSH_CHECKS = [
|
|
||||||
['check:lockfile'],
|
|
||||||
['generate:cli-catalog', '--', '--check'],
|
|
||||||
['check:browser-excludes'],
|
|
||||||
['check:frontend-syntax'],
|
|
||||||
['format:check'],
|
|
||||||
['lint'],
|
|
||||||
['typecheck'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
|
|
||||||
* Derived from what each check reads: src/ (format:check, lint, typecheck,
|
|
||||||
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
|
|
||||||
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
|
|
||||||
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
|
|
||||||
* package.json + package-lock.json (check:lockfile), install.sh (generate:cli-catalog
|
|
||||||
* --check diffs its generated block), tsconfig.json (typecheck, and
|
|
||||||
* config/tsconfig.scripts.json extends it) and .prettierignore + .editorconfig
|
|
||||||
* (format:check; the Prettier CLI honours .editorconfig by default).
|
|
||||||
*/
|
|
||||||
export const PRE_PUSH_WATCHED_PATHS = [
|
|
||||||
'src',
|
|
||||||
'config',
|
|
||||||
'scripts',
|
|
||||||
'test',
|
|
||||||
'package.json',
|
|
||||||
'package-lock.json',
|
|
||||||
'install.sh',
|
|
||||||
'tsconfig.json',
|
|
||||||
'.prettierignore',
|
|
||||||
'.editorconfig',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the pre-push hook script.
|
|
||||||
*
|
|
||||||
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
|
|
||||||
*/
|
|
||||||
export function renderPrePushHook() {
|
|
||||||
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
|
|
||||||
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
|
|
||||||
|
|
||||||
return `#!/bin/sh
|
|
||||||
${PRE_PUSH_MARKER}
|
|
||||||
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
|
|
||||||
# it is regenerated on npm install. Delete the marker line above to take ownership
|
|
||||||
# and the installer will leave your version alone.
|
|
||||||
#
|
|
||||||
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
|
|
||||||
# Skip always: remove this file.
|
|
||||||
|
|
||||||
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
|
|
||||||
|
|
||||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
||||||
cd "$repo_root" || exit 0
|
|
||||||
|
|
||||||
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
|
|
||||||
# npm install). Warn rather than blocking the push on a setup detail.
|
|
||||||
if [ ! -d node_modules ]; then
|
|
||||||
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# GUI git clients and IDEs often run hooks with a minimal PATH that lacks an nvm or
|
|
||||||
# Homebrew Node. Every check would then fail with "npm: not found", so skip instead.
|
|
||||||
command -v npm >/dev/null 2>&1 || { echo "pre-push: npm not on PATH, skipping checks."; exit 0; }
|
|
||||||
|
|
||||||
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
|
|
||||||
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
|
|
||||||
# The checks below read the working tree, so they only say something about a pushed commit
|
|
||||||
# that IS the checked-out HEAD (tags are peeled to their commit first).
|
|
||||||
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
|
|
||||||
has_content=0
|
|
||||||
not_head=''
|
|
||||||
while read -r localref localsha _remoteref _remotesha; do
|
|
||||||
[ -z "$localsha" ] && continue
|
|
||||||
case "$localsha" in
|
|
||||||
0000000000000000000000000000000000000000) ;;
|
|
||||||
*)
|
|
||||||
has_content=1
|
|
||||||
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
|
|
||||||
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ "$has_content" = "0" ] && exit 0
|
|
||||||
|
|
||||||
if [ -n "$not_head" ]; then
|
|
||||||
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
|
|
||||||
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
|
|
||||||
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
|
|
||||||
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
|
|
||||||
trap 'rm -f "$log"' EXIT
|
|
||||||
|
|
||||||
failed=''
|
|
||||||
run_check() {
|
|
||||||
if ! npm run --silent "$@" >"$log" 2>&1; then
|
|
||||||
echo ""
|
|
||||||
echo "pre-push: FAILED npm run $*"
|
|
||||||
tail -n 25 "$log"
|
|
||||||
failed="$failed $1"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "pre-push: running static checks (~10-40s)..."
|
|
||||||
${runs}
|
|
||||||
|
|
||||||
if [ -n "$failed" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "pre-push: blocked by:$failed"
|
|
||||||
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "pre-push: static checks passed."
|
|
||||||
exit 0
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide what to do with an existing hook file.
|
|
||||||
*
|
|
||||||
* @param {{ existing: string | null | undefined, next: string }} args
|
|
||||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
|
||||||
*/
|
|
||||||
export function planHookInstall({ existing, next }) {
|
|
||||||
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
|
|
||||||
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
|
|
||||||
return existing === next ? 'up-to-date' : 'write';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} cwd @param {string[]} args */
|
|
||||||
function git(cwd, args) {
|
|
||||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
|
|
||||||
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
|
|
||||||
*
|
|
||||||
* @param {string} path
|
|
||||||
*/
|
|
||||||
function canonicalPath(path) {
|
|
||||||
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
|
|
||||||
* is nothing to install into.
|
|
||||||
*
|
|
||||||
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
|
|
||||||
* `.git` is a FILE pointing at the parent repo, so the hooks live under
|
|
||||||
* `--git-common-dir`.
|
|
||||||
*
|
|
||||||
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
|
|
||||||
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
|
|
||||||
* shared hooks directory used by every repo on the machine); installing there would
|
|
||||||
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
|
|
||||||
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
|
|
||||||
* the comparison is on canonical paths rather than on whether the setting exists.
|
|
||||||
*
|
|
||||||
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
|
|
||||||
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
|
|
||||||
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
|
|
||||||
*
|
|
||||||
* @param {string} repoRoot
|
|
||||||
* @returns {string | null}
|
|
||||||
*/
|
|
||||||
export function resolveGitHooksDir(repoRoot) {
|
|
||||||
try {
|
|
||||||
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
|
|
||||||
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
|
|
||||||
// Both are printed relative to the cwd (repoRoot) unless already absolute.
|
|
||||||
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
|
|
||||||
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
|
|
||||||
if (!hooks || !common) return null;
|
|
||||||
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
|
|
||||||
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
|
|
||||||
* {@link planHookInstall}: a hook without the marker is never touched.
|
|
||||||
*
|
|
||||||
* @param {string} hooksDir
|
|
||||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
|
||||||
*/
|
|
||||||
export function installPrePushHook(hooksDir) {
|
|
||||||
const path = join(hooksDir, 'pre-push');
|
|
||||||
const next = renderPrePushHook();
|
|
||||||
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
||||||
const action = planHookInstall({ existing, next });
|
|
||||||
if (action === 'write') {
|
|
||||||
mkdirSync(hooksDir, { recursive: true });
|
|
||||||
writeFileSync(path, next, { mode: 0o755 });
|
|
||||||
chmodSync(path, 0o755); // `mode` only applies when the file is created
|
|
||||||
}
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
@@ -64,15 +64,6 @@ export const GIT_HOST_CLI_BUILD_ARGS = [
|
|||||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Environment variable → Dockerfile ARG for the image's system Git identity.
|
|
||||||
* ⚠️ Mirrored by `GIT_IDENTITY_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
|
|
||||||
*/
|
|
||||||
export const GIT_IDENTITY_BUILD_ARGS = [
|
|
||||||
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
|
||||||
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||||
@@ -91,25 +82,9 @@ export function gitHostCliBuildArgPairs(env) {
|
|||||||
return pairs;
|
return pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The `--build-arg` pairs for Git identity, requiring either both values or neither. */
|
|
||||||
export function gitIdentityBuildArgPairs(env) {
|
|
||||||
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']);
|
|
||||||
const configured = pairs.filter(([, value]) => value !== '');
|
|
||||||
if (configured.length === 0) return [];
|
|
||||||
if (configured.length !== pairs.length) {
|
|
||||||
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
|
|
||||||
throw new Error(`${names} must both be set when configuring Git identity`);
|
|
||||||
}
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||||
export function agentImageBuildArgPairs(catalog, env = process.env) {
|
export function agentImageBuildArgPairs(catalog, env = process.env) {
|
||||||
return [
|
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||||
['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')],
|
|
||||||
...gitHostCliBuildArgPairs(env),
|
|
||||||
...gitIdentityBuildArgPairs(env),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the committed catalogue. IO. */
|
/** Read the committed catalogue. IO. */
|
||||||
|
|||||||
+4
-16
@@ -356,17 +356,14 @@ if (!isGlobalInstall) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// 5. Install git hooks (pre-commit format check, pre-push static checks)
|
// 5. Install git pre-commit hook (format check)
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
if (!isGlobalInstall) {
|
if (!isGlobalInstall) {
|
||||||
try {
|
try {
|
||||||
const { writeFileSync, mkdirSync } = await import('fs');
|
const { writeFileSync, mkdirSync } = await import('fs');
|
||||||
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
|
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
|
||||||
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
|
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
|
||||||
// null when this directory is not the top of a git checkout.
|
|
||||||
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
|
|
||||||
if (gitHooksDir) {
|
|
||||||
mkdirSync(gitHooksDir, { recursive: true });
|
mkdirSync(gitHooksDir, { recursive: true });
|
||||||
const hook = `#!/bin/bash
|
const hook = `#!/bin/bash
|
||||||
# Auto-installed by postinstall — prevents CI format failures
|
# Auto-installed by postinstall — prevents CI format failures
|
||||||
@@ -382,18 +379,9 @@ fi
|
|||||||
const hookPath = join(gitHooksDir, 'pre-commit');
|
const hookPath = join(gitHooksDir, 'pre-commit');
|
||||||
writeFileSync(hookPath, hook, { mode: 0o755 });
|
writeFileSync(hookPath, hook, { mode: 0o755 });
|
||||||
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
||||||
|
|
||||||
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
|
|
||||||
// hook the developer wrote themselves is left alone.
|
|
||||||
const action = installPrePushHook(gitHooksDir);
|
|
||||||
if (action === 'write') {
|
|
||||||
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
|
|
||||||
} else if (action === 'skip-foreign') {
|
|
||||||
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — git hooks are a convenience
|
// Non-critical — git hook is a convenience
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -338,15 +338,6 @@ const capabilitiesSchema = z
|
|||||||
// Bounded hard: this is how far up the screen a config file may push the search,
|
// Bounded hard: this is how far up the screen a config file may push the search,
|
||||||
// and every row it adds is one more row the agent itself may be able to write.
|
// and every row it adds is one more row the agent itself may be able to write.
|
||||||
watchingLines: z.number().int().min(1).max(8).optional(),
|
watchingLines: z.number().int().min(1).max(8).optional(),
|
||||||
// Same guard again: tested against a pane row every time a session settles.
|
|
||||||
awaitingLine: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.refine(
|
|
||||||
(src) => compileVersionRegex(src) !== null,
|
|
||||||
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
|
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
|
||||||
|
|||||||
@@ -237,24 +237,7 @@ const CLAUDE: CliEntry = {
|
|||||||
// carry a count. A footer that ever drew the chip as its only item would report no
|
// carry a count. A footer that ever drew the chip as its only item would report no
|
||||||
// watching rather than open that door. See `watchingLabel()` in
|
// watching rather than open that door. See `watchingLabel()` in
|
||||||
// `session-activity.ts`.
|
// `session-activity.ts`.
|
||||||
// ⚠️ An Artifact comment monitor is the one chip that waits on the user. The agent
|
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
|
||||||
// has published a page and hears nothing until somebody comments on it, so the
|
|
||||||
// lookahead refuses the whole row while that chip is on it, whatever else is
|
|
||||||
// running beside it. The `^` is what makes the lookahead judge the row once:
|
|
||||||
// without it the engine retries from each later position, and a start past the
|
|
||||||
// chip reports the shell beside it. The lookahead keys on "Artifact" alone, so a
|
|
||||||
// footer cut off mid-chip (`· 1 Artifact…`, `· 1 Artifact comm…`) is still refused;
|
|
||||||
// no other chip on this row says "Artifact". Counting the chip as watching kept the
|
|
||||||
// idle alert quiet for a session that was waiting for a human.
|
|
||||||
watchingLine: String.raw`^(?!.*Artifact).*?·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?))`,
|
|
||||||
// When a turn ends while background agents or an ultracode workflow are still
|
|
||||||
// running, Claude swaps its `✻ Brewed for 1m 18s` closing row for
|
|
||||||
// `✻ Waiting for 2 background agents and 1 dynamic workflow to finish` and resumes
|
|
||||||
// by itself when they report back. Read from the 2.1.283 bundle (the turn-duration
|
|
||||||
// renderer) and a live pane on 2026-09-28. The row is a snapshot taken at turn end
|
|
||||||
// and never redrawn, which is why only the newest row above the composer counts.
|
|
||||||
// Anchored on column 0: Claude's own rows start there, the agent's prose never does.
|
|
||||||
awaitingLine: String.raw`^✻ Waiting for \d+ (?:background agents?|dynamic workflows?)\b`,
|
|
||||||
},
|
},
|
||||||
requiresMux: false,
|
requiresMux: false,
|
||||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||||
@@ -263,8 +246,6 @@ const CLAUDE: CliEntry = {
|
|||||||
transcript: 'claude-jsonl',
|
transcript: 'claude-jsonl',
|
||||||
altScreen: 'strip-full',
|
altScreen: 'strip-full',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } },
|
echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } },
|
||||||
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
|
|
||||||
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
|
|
||||||
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
|
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
|
||||||
keyboardAccessory: 'agent',
|
keyboardAccessory: 'agent',
|
||||||
privilegedCommandGate: false,
|
privilegedCommandGate: false,
|
||||||
|
|||||||
@@ -362,19 +362,6 @@ export interface CliCapabilities {
|
|||||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||||
*/
|
*/
|
||||||
watchingLines?: number;
|
watchingLines?: number;
|
||||||
/**
|
|
||||||
* Source of a regex matching the row this CLI closes a turn with when it ended that
|
|
||||||
* turn to WAIT for workers it started and will resume on its own once they finish,
|
|
||||||
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`. A pane showing it counts
|
|
||||||
* as working, not idle: nothing is being asked of the user, and the next turn starts
|
|
||||||
* without them.
|
|
||||||
*
|
|
||||||
* Unlike `workingLine` this is never searched across the pane. The CLI prints the row
|
|
||||||
* once and never updates it, so the copy from an earlier turn is still on screen after
|
|
||||||
* the workers are done. Only the newest transcript row directly above the composer is
|
|
||||||
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
|
|
||||||
*/
|
|
||||||
awaitingLine?: string;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||||
|
|||||||
@@ -99,11 +99,3 @@ export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
|
|||||||
|
|
||||||
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
||||||
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
|
||||||
* Gap between a paste-mode cron prompt's text and its Enter (ms). The two must be
|
|
||||||
* separate writes: Claude Code takes a raw `<text>\r` burst of about a hundred
|
|
||||||
* characters as a paste and turns its `\r` into a newline. A separate `\r` 80 ms
|
|
||||||
* after the text was measured to submit; this leaves room for a longer prompt.
|
|
||||||
*/
|
|
||||||
export const CRON_PASTE_ENTER_DELAY_MS = 300;
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api
|
|||||||
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
||||||
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
||||||
import { CRON_PASTE_ENTER_DELAY_MS, CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BLOCKED_TREES,
|
DEFAULT_BLOCKED_TREES,
|
||||||
isBlockedAttachmentPath,
|
isBlockedAttachmentPath,
|
||||||
@@ -100,41 +100,6 @@ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TR
|
|||||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||||
const HAS_NEWLINE = /[\r\n]/;
|
const HAS_NEWLINE = /[\r\n]/;
|
||||||
|
|
||||||
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
|
|
||||||
type CronPromptTarget = Pick<Session, 'write' | 'writeViaMux' | 'verifySubmitted'>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a cron job's (single-line) prompt into its session and press Enter.
|
|
||||||
*
|
|
||||||
* `typed` goes through the mux: the text is typed, Enter is its own key, and the
|
|
||||||
* session re-presses it while the prompt is still on the composer.
|
|
||||||
*
|
|
||||||
* `paste` writes the text straight into the PTY, and must send its Enter as a
|
|
||||||
* SEPARATE write. It used to send `<text>\r` in one piece, and Claude Code (measured
|
|
||||||
* on 2.1.283) takes a burst of about a hundred characters as a paste, so the `\r`
|
|
||||||
* landed as a newline and the prompt sat unsent while the run reported
|
|
||||||
* `prompt_sent`. The Enter goes down the same PTY as the text, so it cannot overtake
|
|
||||||
* it, and the same composer check then covers a CLI that was not taking Enter yet.
|
|
||||||
*
|
|
||||||
* @returns false when the session had no PTY or mux to write to
|
|
||||||
*/
|
|
||||||
export async function deliverCronPrompt(
|
|
||||||
target: CronPromptTarget,
|
|
||||||
prompt: string,
|
|
||||||
inputMode: CronJob['inputMode'],
|
|
||||||
wait: (ms: number) => Promise<void> = delay
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (inputMode !== 'paste') {
|
|
||||||
return target.writeViaMux(prompt.endsWith('\r') ? prompt : `${prompt}\r`);
|
|
||||||
}
|
|
||||||
const text = prompt.replace(/[\r\n]+$/, '');
|
|
||||||
if (!target.write(text)) return false;
|
|
||||||
await wait(CRON_PASTE_ENTER_DELAY_MS);
|
|
||||||
if (!target.write('\r')) return false;
|
|
||||||
target.verifySubmitted(text);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Order-insensitive equality for the weekly-days arrays. */
|
/** Order-insensitive equality for the weekly-days arrays. */
|
||||||
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
||||||
const x = [...(a ?? [])].sort((p, q) => p - q);
|
const x = [...(a ?? [])].sort((p, q) => p - q);
|
||||||
@@ -658,9 +623,15 @@ export class CronService {
|
|||||||
const s = this.deps.sessions.get(sessionId);
|
const s = this.deps.sessions.get(sessionId);
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
try {
|
try {
|
||||||
const delivered = await deliverCronPrompt(s, prompt, job.inputMode);
|
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
|
||||||
|
let delivered = true;
|
||||||
|
if (job.inputMode === 'paste') {
|
||||||
|
s.write(payload);
|
||||||
|
} else {
|
||||||
|
delivered = await s.writeViaMux(payload);
|
||||||
|
}
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
this.failRun(job, run, 'Failed to send prompt: the session could not be written to');
|
this.failRun(job, run, 'Failed to send prompt: mux write failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run.status = 'prompt_sent';
|
run.status = 'prompt_sent';
|
||||||
|
|||||||
+2
-31
@@ -615,15 +615,6 @@ export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> =
|
|||||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Environment variable → Dockerfile ARG for the image's system Git identity.
|
|
||||||
* ⚠️ Mirrors `GIT_IDENTITY_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
|
||||||
*/
|
|
||||||
export const GIT_IDENTITY_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
|
|
||||||
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
|
||||||
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||||
@@ -642,29 +633,9 @@ export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string,
|
|||||||
return pairs;
|
return pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The `--build-arg` pairs for a configured Git identity. An absent pair leaves
|
|
||||||
* Git unconfigured, preserving existing deployments; a partial pair is refused.
|
|
||||||
* ⚠️ Mirrors `gitIdentityBuildArgPairs()` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
|
||||||
*/
|
|
||||||
export function gitIdentityBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
|
|
||||||
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? ''] as [string, string]);
|
|
||||||
const configured = pairs.filter(([, value]) => value !== '');
|
|
||||||
if (configured.length === 0) return [];
|
|
||||||
if (configured.length !== pairs.length) {
|
|
||||||
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
|
|
||||||
throw new Error(`${names} must both be set when configuring Git identity`);
|
|
||||||
}
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||||
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
|
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
|
||||||
return [
|
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||||
['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')],
|
|
||||||
...gitHostCliBuildArgPairs(env),
|
|
||||||
...gitIdentityBuildArgPairs(env),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Credential mount resolution (IO) ==========
|
// ========== Credential mount resolution (IO) ==========
|
||||||
@@ -1233,7 +1204,7 @@ function buildAgentImage(
|
|||||||
try {
|
try {
|
||||||
buildArgPairs = agentImageBuildArgPairs();
|
buildArgPairs = agentImageBuildArgPairs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A malformed CODEMAN_AGENT_IMAGE_* value: report it like any other build failure.
|
// A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure.
|
||||||
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
|
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
|
||||||
}
|
}
|
||||||
const argv = dockerEngineArgv(docker);
|
const argv = dockerEngineArgv(docker);
|
||||||
|
|||||||
@@ -336,14 +336,6 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
|
||||||
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
|
||||||
* only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`),
|
|
||||||
* and a multiplexer without this method never has a session closed by it.
|
|
||||||
*/
|
|
||||||
getPaneExitReadCount?(muxName: string): number;
|
|
||||||
|
|
||||||
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
|
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
|
||||||
clearPaneExit?(muxName: string): void;
|
clearPaneExit?(muxName: string): void;
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446).
|
|
||||||
*
|
|
||||||
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
|
|
||||||
* CLI while the pane, the tmux session and the `tmux attach-session` process
|
|
||||||
* all live on. Part 1 of #446 records that as `SessionState.paneExit`. This
|
|
||||||
* module decides when such a session is closed, the way the X button closes
|
|
||||||
* it, so finished sessions stop piling up on the board.
|
|
||||||
*
|
|
||||||
* The rule closes a session only on a POSITIVE observation of a clean exit:
|
|
||||||
*
|
|
||||||
* - The exit status must be an explicit numeric 0 with no signal. An absent
|
|
||||||
* status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a
|
|
||||||
* status nor a signal, so reading absence as clean would sweep an agent the
|
|
||||||
* OOM killer took. A non-zero status or any signal keeps the row, marked with
|
|
||||||
* the exit, as the crash evidence #210 was filed to keep.
|
|
||||||
* - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must
|
|
||||||
* have agreed on that exit. A failed, empty or skipped read counts for
|
|
||||||
* nothing, because unknown never closes anything.
|
|
||||||
* - No start, attach or relaunch may be in flight for the session. The
|
|
||||||
* dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited
|
|
||||||
* pane on purpose, and for a few seconds that pane still reads as dead.
|
|
||||||
* - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after
|
|
||||||
* the last start, attach or relaunch finished. A CLI that prints a startup
|
|
||||||
* error ("not logged in", a bad profile, a config error) and exits 0 would
|
|
||||||
* otherwise lose its tab, and the error with it, seconds after launch. Its
|
|
||||||
* row stays, marked `exited (0)`, for the user to read and close.
|
|
||||||
*
|
|
||||||
* Scoping to local mux-backed sessions happens before this rule runs:
|
|
||||||
* `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote,
|
|
||||||
* docker and discovered sessions, so their `paneExit` never reaches here.
|
|
||||||
*
|
|
||||||
* Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts).
|
|
||||||
*/
|
|
||||||
import type { PaneExit } from './types/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many authoritative pane reads must agree on a clean exit before the
|
|
||||||
* session is closed. At the watcher's 2 s cadence two reads mean a finished
|
|
||||||
* session disappears within about four seconds of its agent exiting.
|
|
||||||
*/
|
|
||||||
export const CLEAN_EXIT_CONFIRMING_READS = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a pane must have been up before a clean exit closes its session.
|
|
||||||
* An exit sooner than this after the last pane start is read as a startup
|
|
||||||
* failure rather than a user ending the agent, and the row is kept.
|
|
||||||
*/
|
|
||||||
export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000;
|
|
||||||
|
|
||||||
/** The lifecycle-log reason recorded when the sweep closes a session. */
|
|
||||||
export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is this exit a clean one? True only for an explicit numeric status of 0 with
|
|
||||||
* no signal reported.
|
|
||||||
*
|
|
||||||
* ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was
|
|
||||||
* clean". An absent status is how a signal death presents on tmux 3.2a, and
|
|
||||||
* that shortcut would close crashed agents with nothing failing to warn you.
|
|
||||||
*/
|
|
||||||
export function isCleanPaneExit(exit: PaneExit | undefined): boolean {
|
|
||||||
if (!exit) return false;
|
|
||||||
if (exit.signal !== undefined) return false;
|
|
||||||
return exit.status === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything the sweep needs to know about one session. */
|
|
||||||
export interface CleanExitSweepCandidate {
|
|
||||||
/** The session's published exit, already scoped by `Session.setPaneExit()`. */
|
|
||||||
paneExit: PaneExit | undefined;
|
|
||||||
/** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */
|
|
||||||
confirmingReads: number;
|
|
||||||
/** A start, attach or relaunch is running for this session's pane. */
|
|
||||||
paneLifecycleInFlight: boolean;
|
|
||||||
/** The session is already being closed or detached. */
|
|
||||||
closing: boolean;
|
|
||||||
/**
|
|
||||||
* When the last start, attach or relaunch of this pane finished
|
|
||||||
* (`Session.paneStartedAt`), or 0 when none has run in this process.
|
|
||||||
*/
|
|
||||||
paneStartedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Should the sweep close this session now? See the file overview for the rule. */
|
|
||||||
export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean {
|
|
||||||
if (candidate.closing) return false;
|
|
||||||
if (candidate.paneLifecycleInFlight) return false;
|
|
||||||
if (!isCleanPaneExit(candidate.paneExit)) return false;
|
|
||||||
// `at` is when this server first read the pane dead, so an exit during the
|
|
||||||
// start itself lands BEFORE `paneStartedAt` and is kept too.
|
|
||||||
if (
|
|
||||||
candidate.paneStartedAt > 0 &&
|
|
||||||
candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS;
|
|
||||||
}
|
|
||||||
+14
-27
@@ -19,22 +19,19 @@
|
|||||||
* touching its status, so a pinned session a reboot killed still reads `idle` or
|
* touching its status, so a pinned session a reboot killed still reads `idle` or
|
||||||
* `busy` and stays eligible.
|
* `busy` and stays eligible.
|
||||||
*
|
*
|
||||||
* ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
|
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
|
||||||
* `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
|
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the
|
||||||
* and the PTY Codeman owns is the `tmux attach-session` process, which stays
|
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
|
||||||
* alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
|
* `tmux attach-session` process, which stays alive throughout — so no exit
|
||||||
* and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
|
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
|
||||||
* handles it in two steps. The pane-exit watcher persists `paneExit`, and the
|
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was
|
||||||
* clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
|
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
|
||||||
* with status 0 through `cleanupSession()`, which leaves the durable record
|
* notice the dead pane; until a record can say the agent is gone, this pass will
|
||||||
* described above. This module also refuses a record whose persisted
|
* offer those sessions back, and the user dismisses or closes them.
|
||||||
* `paneExit` is a clean exit, which covers a session that exited moments
|
|
||||||
* before the power went, before the sweep reached it. A crashed agent's record
|
|
||||||
* stays eligible, like the row the sweep leaves on the board for it.
|
|
||||||
*
|
*
|
||||||
* The `pid` check below is NOT that rule. It refuses a record whose attach
|
* The `pid` check below is therefore NOT that rule. It refuses a record whose
|
||||||
* process was already gone, which is a session that never started or whose
|
* attach process was already gone, which is a session that never started or
|
||||||
* pane died outright.
|
* whose pane died outright.
|
||||||
*
|
*
|
||||||
* @dependencies types (SessionState), config/cli-registry
|
* @dependencies types (SessionState), config/cli-registry
|
||||||
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
||||||
@@ -44,7 +41,6 @@
|
|||||||
|
|
||||||
import type { SessionState } from './types.js';
|
import type { SessionState } from './types.js';
|
||||||
import { getCli } from './config/cli-registry/registry.js';
|
import { getCli } from './config/cli-registry/registry.js';
|
||||||
import { isCleanPaneExit } from './pane-exit-sweep.js';
|
|
||||||
|
|
||||||
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
|
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
|
||||||
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
|
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
|
||||||
@@ -113,7 +109,7 @@ export function resolveResumeConversationId(state: SessionState): string {
|
|||||||
/**
|
/**
|
||||||
* Why one session was passed over. Reported for logging and shown to the user.
|
* Why one session was passed over. Reported for logging and shown to the user.
|
||||||
*
|
*
|
||||||
* All but the last two are decided before anything is built. `capacity-reached` and
|
* The first seven are decided before anything is built. `capacity-reached` and
|
||||||
* `rebuild-failed` can only happen once a click is spending the plan, and they
|
* `rebuild-failed` can only happen once a click is spending the plan, and they
|
||||||
* are the two the banner must not confuse with a missing workspace: one means
|
* are the two the banner must not confuse with a missing workspace: one means
|
||||||
* "try again after closing something", the other means the CLI would not start.
|
* "try again after closing something", the other means the CLI would not start.
|
||||||
@@ -124,7 +120,6 @@ export interface RebootRestoreRejection {
|
|||||||
| 'no-persisted-record'
|
| 'no-persisted-record'
|
||||||
| 'intentionally-ended'
|
| 'intentionally-ended'
|
||||||
| 'not-running'
|
| 'not-running'
|
||||||
| 'agent-exited'
|
|
||||||
| 'respawn-blocked'
|
| 'respawn-blocked'
|
||||||
| 'remote-or-docker'
|
| 'remote-or-docker'
|
||||||
| 'unsupported-mode'
|
| 'unsupported-mode'
|
||||||
@@ -196,8 +191,7 @@ export function planRebootRestore(
|
|||||||
//
|
//
|
||||||
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
|
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
|
||||||
// module header: that leaves the pid in place, because the pid is the tmux
|
// module header: that leaves the pid in place, because the pid is the tmux
|
||||||
// attach process and `remain-on-exit` keeps it alive. The `paneExit` check
|
// attach process and `remain-on-exit` keeps it alive.
|
||||||
// below catches it instead.
|
|
||||||
//
|
//
|
||||||
// Conservative on purpose. A session that somehow persisted no pid while
|
// Conservative on purpose. A session that somehow persisted no pid while
|
||||||
// genuinely running is not offered, and its conversation stays reachable
|
// genuinely running is not offered, and its conversation stays reachable
|
||||||
@@ -206,13 +200,6 @@ export function planRebootRestore(
|
|||||||
skipped.push({ sessionId, reason: 'not-running' });
|
skipped.push({ sessionId, reason: 'not-running' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isCleanPaneExit(state.paneExit)) {
|
|
||||||
// The user ended the agent, and the clean-exit sweep would have closed the
|
|
||||||
// session had the power not gone first (Ark0N/Codeman#446). The same
|
|
||||||
// explicit-0 rule applies: an absent status is unknown, not clean.
|
|
||||||
skipped.push({ sessionId, reason: 'agent-exited' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (state.respawnBlocked === true) {
|
if (state.respawnBlocked === true) {
|
||||||
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
|
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
|
||||||
skipped.push({ sessionId, reason: 'respawn-blocked' });
|
skipped.push({ sessionId, reason: 'respawn-blocked' });
|
||||||
|
|||||||
@@ -158,59 +158,3 @@ export function watchingLabel(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How many rows above the composer the turn's closing row may sit. Between the two Claude
|
|
||||||
* draws only its composer border and, sometimes, a right-aligned hint
|
|
||||||
* (`new task? /clear to save 169.1k tokens`), so this leaves room for a blank row or two
|
|
||||||
* and no more. A bound, not a tuning knob: the walk must never reach far enough up the
|
|
||||||
* transcript to find an old turn's row.
|
|
||||||
*/
|
|
||||||
export const AWAITING_SEARCH_ROWS = 6;
|
|
||||||
|
|
||||||
/** A row that opens with a box-drawing character is the composer's frame, not transcript. */
|
|
||||||
const COMPOSER_FRAME_ROW = /^[─-╿]/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the pane's newest turn ended by handing off to workers the CLI will wait for,
|
|
||||||
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`.
|
|
||||||
*
|
|
||||||
* Such a pane is quiet and shows its composer, so every other signal calls it idle, yet
|
|
||||||
* nothing is being asked of the user: the CLI resumes by itself when the workers report
|
|
||||||
* back. That is why a session in this state counts as working.
|
|
||||||
*
|
|
||||||
* ⚠️ The row is a snapshot. Claude renders it once, at the end of the turn, and never
|
|
||||||
* updates it, so after the workers finish the same words are still on screen above the
|
|
||||||
* follow-up turn. Matching them anywhere on the pane would pin the session busy for as
|
|
||||||
* long as they stay visible. Only the newest transcript row counts: the walk starts at
|
|
||||||
* the composer (the LAST row carrying `promptGlyph`), steps up past blank rows, the
|
|
||||||
* composer's frame and anything indented (a right-aligned hint, a wrapped continuation),
|
|
||||||
* and tests the first row that starts in column 0. A follow-up turn always puts rows of
|
|
||||||
* its own there, so the stale copy is never the one tested.
|
|
||||||
*
|
|
||||||
* @param promptGlyph the CLI's composer glyph (`capabilities.workDetect.promptGlyph`)
|
|
||||||
* @returns false when the screen shows no composer, which is no evidence either way
|
|
||||||
*/
|
|
||||||
export function isAwaitingWorkers(paneText: string | null | undefined, pattern: RegExp, promptGlyph: string): boolean {
|
|
||||||
if (!paneText) return false;
|
|
||||||
const rows = stripAnsi(paneText)
|
|
||||||
.split('\n')
|
|
||||||
.map((row) => row.trimEnd());
|
|
||||||
let composer = -1;
|
|
||||||
for (let i = rows.length - 1; i >= 0; i--) {
|
|
||||||
// Claude has drawn its composer both bare (`❯ …` between rules) and boxed (`│ ❯ … │`).
|
|
||||||
if (rows[i].replace(/^[\s│]+/, '').startsWith(promptGlyph)) {
|
|
||||||
composer = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (composer < 0) return false;
|
|
||||||
for (let i = composer - 1; i >= Math.max(0, composer - AWAITING_SEARCH_ROWS); i--) {
|
|
||||||
const row = rows[i];
|
|
||||||
if (row === '' || /^\s/.test(row) || COMPOSER_FRAME_ROW.test(row)) continue;
|
|
||||||
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
|
|
||||||
pattern.lastIndex = 0;
|
|
||||||
return pattern.test(row);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-108
@@ -85,7 +85,6 @@ import {
|
|||||||
isSustainedActivity,
|
isSustainedActivity,
|
||||||
isPaneQuiet,
|
isPaneQuiet,
|
||||||
watchingLabel,
|
watchingLabel,
|
||||||
isAwaitingWorkers,
|
|
||||||
WATCHING_TAIL_LINES,
|
WATCHING_TAIL_LINES,
|
||||||
IDLE_RECHECK_MS,
|
IDLE_RECHECK_MS,
|
||||||
PANE_PROBE_MIN_INTERVAL_MS,
|
PANE_PROBE_MIN_INTERVAL_MS,
|
||||||
@@ -532,8 +531,6 @@ export class Session extends EventEmitter {
|
|||||||
private _watchingLineRe: RegExp | null | undefined = undefined;
|
private _watchingLineRe: RegExp | null | undefined = undefined;
|
||||||
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
|
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
|
||||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
|
||||||
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
|
||||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||||
@@ -585,21 +582,6 @@ export class Session extends EventEmitter {
|
|||||||
* rendered as "alive".
|
* rendered as "alive".
|
||||||
*/
|
*/
|
||||||
private _paneExit: PaneExit | null = null;
|
private _paneExit: PaneExit | null = null;
|
||||||
/**
|
|
||||||
* How many starts, attaches or relaunches are running for this session's
|
|
||||||
* pane. While one is, a dead-pane reading may describe a pane that is being
|
|
||||||
* revived on purpose, so the exited-agent sweep leaves the session alone
|
|
||||||
* (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping
|
|
||||||
* operations cannot clear each other's mark.
|
|
||||||
*/
|
|
||||||
private _paneLifecycleOps = 0;
|
|
||||||
/** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */
|
|
||||||
private _paneStartedAt = 0;
|
|
||||||
/**
|
|
||||||
* The server has started closing this session, so no start or attach may
|
|
||||||
* begin (see {@link markClosing}).
|
|
||||||
*/
|
|
||||||
private _closing = false;
|
|
||||||
/**
|
/**
|
||||||
* This session was rebuilt from the tmux socket rather than from Codeman's
|
* This session was rebuilt from the tmux socket rather than from Codeman's
|
||||||
* own records, so its `remote`/`docker` metadata is missing rather than known
|
* own records, so its `remote`/`docker` metadata is missing rather than known
|
||||||
@@ -1201,49 +1183,6 @@ export class Session extends EventEmitter {
|
|||||||
return this._paneExit ?? undefined;
|
return this._paneExit ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True while a start, attach or relaunch is running for this session's pane.
|
|
||||||
* The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane
|
|
||||||
* branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and
|
|
||||||
* until it finishes and clears the exit, the pane still reads as dead.
|
|
||||||
*/
|
|
||||||
get paneLifecycleInFlight(): boolean {
|
|
||||||
return this._paneLifecycleOps > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the last start, attach or relaunch of this pane finished, or 0 when
|
|
||||||
* none has run in this process. The exited-agent sweep keeps an exit that
|
|
||||||
* lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a
|
|
||||||
* CLI failing at startup rather than a user ending it. An attach to a pane
|
|
||||||
* that was already running stamps it too, which only costs a user who
|
|
||||||
* `/exit`s within seconds of a server restart a row to close by hand.
|
|
||||||
*/
|
|
||||||
get paneStartedAt(): number {
|
|
||||||
return this._paneStartedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark this session as being closed, or clear the mark after a close that
|
|
||||||
* failed. While it is set, {@link startInteractive} and {@link startShell}
|
|
||||||
* refuse to run. A start that raced a close would otherwise launch a CLI in a
|
|
||||||
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
|
|
||||||
*/
|
|
||||||
markClosing(closing: boolean): void {
|
|
||||||
this._closing = closing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
|
|
||||||
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
|
|
||||||
this._paneLifecycleOps++;
|
|
||||||
try {
|
|
||||||
return await op();
|
|
||||||
} finally {
|
|
||||||
this._paneLifecycleOps--;
|
|
||||||
this._paneStartedAt = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forget this pane's exit, on both this record and the mux layer's cache.
|
* Forget this pane's exit, on both this record and the mux layer's cache.
|
||||||
* Every path that starts or relaunches a command in the pane calls it, and
|
* Every path that starts or relaunches a command in the pane calls it, and
|
||||||
@@ -1949,14 +1888,6 @@ export class Session extends EventEmitter {
|
|||||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||||
spawnErrLabel: string;
|
spawnErrLabel: string;
|
||||||
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
|
||||||
return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _doSetupOrAttachMuxSession(options: {
|
|
||||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
|
||||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
|
||||||
spawnErrLabel: string;
|
|
||||||
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
||||||
const mux = this._mux!;
|
const mux = this._mux!;
|
||||||
|
|
||||||
@@ -2140,10 +2071,6 @@ export class Session extends EventEmitter {
|
|||||||
* the mux session is gone — see {@link reattachRemote} for that reasoning).
|
* the mux session is gone — see {@link reattachRemote} for that reasoning).
|
||||||
*/
|
*/
|
||||||
async restartCli(): Promise<boolean> {
|
async restartCli(): Promise<boolean> {
|
||||||
return this._withPaneLifecycle(() => this._doRestartCli());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _doRestartCli(): Promise<boolean> {
|
|
||||||
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||||
const mux = this._mux;
|
const mux = this._mux;
|
||||||
|
|
||||||
@@ -2532,9 +2459,6 @@ export class Session extends EventEmitter {
|
|||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
throw new Error('Session already has a running process');
|
throw new Error('Session already has a running process');
|
||||||
}
|
}
|
||||||
if (this._closing) {
|
|
||||||
throw new Error('Session is being closed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
||||||
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
||||||
@@ -3096,10 +3020,7 @@ export class Session extends EventEmitter {
|
|||||||
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
|
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
|
||||||
this._lastPaneProbeAt = now;
|
this._lastPaneProbeAt = now;
|
||||||
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
||||||
// A turn that ended by handing off to workers the CLI waits for is work too: the
|
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
|
||||||
// composer is up and the pane is quiet, but the next turn starts without the user.
|
|
||||||
this._lastPaneProbeWorking =
|
|
||||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
|
||||||
this._readWatching(text);
|
this._readWatching(text);
|
||||||
return this._lastPaneProbeWorking;
|
return this._lastPaneProbeWorking;
|
||||||
}
|
}
|
||||||
@@ -3157,21 +3078,6 @@ export class Session extends EventEmitter {
|
|||||||
return this._watchingLineRe;
|
return this._watchingLineRe;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the newest turn on this screen ended waiting for workers the CLI started
|
|
||||||
* (Claude's `✻ Waiting for 1 dynamic workflow to finish`). False for a CLI whose
|
|
||||||
* registry entry declares no `awaitingLine`. See `isAwaitingWorkers()`.
|
|
||||||
*/
|
|
||||||
private _paneAwaitsWorkers(paneText: string): boolean {
|
|
||||||
if (this._awaitingLineRe === undefined) {
|
|
||||||
const src = getCli(this.mode)?.capabilities.workDetect?.awaitingLine;
|
|
||||||
this._awaitingLineRe = src ? compileVersionRegex(src) : null;
|
|
||||||
}
|
|
||||||
if (!this._awaitingLineRe) return false;
|
|
||||||
const glyph = getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯';
|
|
||||||
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regex matching this CLI's "a turn is running" status line.
|
* The regex matching this CLI's "a turn is running" status line.
|
||||||
*
|
*
|
||||||
@@ -3374,9 +3280,6 @@ export class Session extends EventEmitter {
|
|||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
throw new Error('Session already has a running process');
|
throw new Error('Session already has a running process');
|
||||||
}
|
}
|
||||||
if (this._closing) {
|
|
||||||
throw new Error('Session is being closed');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._resetBuffers();
|
this._resetBuffers();
|
||||||
|
|
||||||
@@ -4207,16 +4110,6 @@ export class Session extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Arm the composer check for a prompt that went out some other way than
|
|
||||||
* `writeViaMux`, e.g. cron's paste mode, which writes the body raw and its Enter
|
|
||||||
* separately. `text` is what the composer line starts with while the prompt is still
|
|
||||||
* unsent; the check re-presses Enter only while that holds.
|
|
||||||
*/
|
|
||||||
verifySubmitted(text: string): void {
|
|
||||||
this._verifySubmitted(`${text}\r`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
|
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
|
||||||
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
||||||
|
|||||||
+2
-28
@@ -287,19 +287,6 @@ export interface PaneExitObservation {
|
|||||||
exit: PaneExit;
|
exit: PaneExit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A {@link PaneExitObservation} as the manager stores it, with a count of the
|
|
||||||
* authoritative reads that have seen this same exit. The count is what lets
|
|
||||||
* the exited-agent sweep act only on a death that more than one read agreed on
|
|
||||||
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
|
|
||||||
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
|
|
||||||
* the count nor resets it.
|
|
||||||
*/
|
|
||||||
interface TrackedPaneExit extends PaneExitObservation {
|
|
||||||
/** Authoritative reads that saw this exit, counting the first. */
|
|
||||||
reads: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
|
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
|
||||||
function paneField(fields: string[], index: number): number | undefined {
|
function paneField(fields: string[], index: number): number | undefined {
|
||||||
const raw = fields[index];
|
const raw = fields[index];
|
||||||
@@ -1685,7 +1672,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* lives on `Session`, because the remote-reconnect watcher above needs the
|
* lives on `Session`, because the remote-reconnect watcher above needs the
|
||||||
* raw pane reading.
|
* raw pane reading.
|
||||||
*/
|
*/
|
||||||
private paneExits: Map<string, TrackedPaneExit> = new Map();
|
private paneExits: Map<string, PaneExitObservation> = new Map();
|
||||||
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||||
private paneExitInterval: NodeJS.Timeout | null = null;
|
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||||
/**
|
/**
|
||||||
@@ -3088,16 +3075,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
return this.paneExits.get(muxName)?.exit;
|
return this.paneExits.get(muxName)?.exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How many authoritative pane reads have agreed on the exit that
|
|
||||||
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
|
|
||||||
* starts at 1, and every later read that sees the same pane with the same
|
|
||||||
* status and signal adds one.
|
|
||||||
*/
|
|
||||||
getPaneExitReadCount(muxName: string): number {
|
|
||||||
return this.paneExits.get(muxName)?.reads ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
|
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
|
||||||
* `tmux list-panes -a` answers for every session at once, which is why this
|
* `tmux list-panes -a` answers for every session at once, which is why this
|
||||||
@@ -3193,9 +3170,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* changed status, a changed signal, or a different pane pid all start a new
|
* changed status, a changed signal, or a different pane pid all start a new
|
||||||
* observation — the pid is what catches a second command in the same pane
|
* observation — the pid is what catches a second command in the same pane
|
||||||
* that happened to exit the same way.
|
* that happened to exit the same way.
|
||||||
*
|
|
||||||
* The same rule decides the read count: a repeat of the stored exit adds one,
|
|
||||||
* and anything that starts a new observation starts the count again at 1.
|
|
||||||
*/
|
*/
|
||||||
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
|
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
|
||||||
for (const muxName of [...this.paneExits.keys()]) {
|
for (const muxName of [...this.paneExits.keys()]) {
|
||||||
@@ -3208,7 +3182,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
prev.panePid === next.panePid &&
|
prev.panePid === next.panePid &&
|
||||||
prev.exit.status === next.exit.status &&
|
prev.exit.status === next.exit.status &&
|
||||||
prev.exit.signal === next.exit.signal;
|
prev.exit.signal === next.exit.signal;
|
||||||
this.paneExits.set(muxName, sameExit ? { ...prev, reads: prev.reads + 1 } : { ...next, reads: 1 });
|
this.paneExits.set(muxName, sameExit ? prev : next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -649,13 +649,12 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
|||||||
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
|
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
|
||||||
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
|
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
|
||||||
* the absent status IS how a signal death presents, so absent-stays-absent is
|
* the absent status IS how a signal death presents, so absent-stays-absent is
|
||||||
* the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away
|
* the only thing keeping a future clean-exit sweep away from crashed agents:
|
||||||
* from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
|
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing
|
||||||
* read as a user typing `/exit` and be closed. Nothing here fails when
|
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the
|
||||||
* somebody adds that `??` — the types allow it and the label still renders.
|
* types allow it, the label still renders, and the damage shows up only once
|
||||||
* The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
|
* the sweep lands. The rule is enforced in `derivePaneExits()`
|
||||||
* the key rather than defaulting it, and again in `isCleanPaneExit()`, which
|
* (`tmux-manager.ts`), which omits the key rather than defaulting it.
|
||||||
* accepts only an explicit 0.
|
|
||||||
*/
|
*/
|
||||||
export interface PaneExit {
|
export interface PaneExit {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
* image dir.
|
* image dir.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { realpathSync } from 'node:fs';
|
import { join } from 'node:path';
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import type { SessionPort } from './ports/index.js';
|
import type { SessionPort } from './ports/index.js';
|
||||||
|
|
||||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
@@ -54,73 +53,6 @@ export async function sweepPasteImagesOnce(
|
|||||||
return { scanned, deleted };
|
return { scanned, deleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The path two sessions must share to share a paste-image dir: the canonical
|
|
||||||
* path when it can be resolved, so a sibling that reaches the same directory
|
|
||||||
* through a symlink matches, and the normalised path otherwise (a directory
|
|
||||||
* that no longer exists has nothing left to protect).
|
|
||||||
*/
|
|
||||||
function canonicalDir(dir: string): string {
|
|
||||||
try {
|
|
||||||
return realpathSync(dir);
|
|
||||||
} catch {
|
|
||||||
return resolve(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
|
|
||||||
export interface PasteImageDirUser {
|
|
||||||
id: string;
|
|
||||||
workingDir: string;
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Does another live session still use this working directory's paste-image
|
|
||||||
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
|
|
||||||
* and several sessions routinely share one case directory, so without this
|
|
||||||
* check closing one session deletes the pasted images a sibling in the same
|
|
||||||
* case still refers to.
|
|
||||||
*
|
|
||||||
* Two kinds of sibling count as live:
|
|
||||||
*
|
|
||||||
* - a session in the server's map, unless it is itself being killed;
|
|
||||||
* - a persisted record whose status is not `stopped`. That covers a session
|
|
||||||
* detached with `killMux=false`, which leaves the server's map while its
|
|
||||||
* tmux pane keeps running, and a session whose detach is still in progress.
|
|
||||||
*
|
|
||||||
* A session being KILLED does not count. Without that exemption, killing two
|
|
||||||
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
|
|
||||||
* closing two panes on one tick) would have each defer to the other, and
|
|
||||||
* neither would remove the dir.
|
|
||||||
*
|
|
||||||
* Erring toward "in use" only costs a missed deletion, which the periodic
|
|
||||||
* sweep above ages out. A pinned record whose tmux session is gone keeps its
|
|
||||||
* status through boot pruning, so it holds the dir this way until unpinned.
|
|
||||||
*/
|
|
||||||
export function pasteImageDirInUseByOtherSession(input: {
|
|
||||||
live: Iterable<PasteImageDirUser>;
|
|
||||||
persisted: Iterable<PasteImageDirUser>;
|
|
||||||
closingId: string;
|
|
||||||
workingDir: string;
|
|
||||||
killing: ReadonlySet<string>;
|
|
||||||
}): boolean {
|
|
||||||
const target = canonicalDir(input.workingDir);
|
|
||||||
const matches = (user: PasteImageDirUser): boolean =>
|
|
||||||
user.id !== input.closingId &&
|
|
||||||
!input.killing.has(user.id) &&
|
|
||||||
!!user.workingDir &&
|
|
||||||
canonicalDir(user.workingDir) === target;
|
|
||||||
for (const user of input.live) {
|
|
||||||
if (matches(user)) return true;
|
|
||||||
}
|
|
||||||
for (const user of input.persisted) {
|
|
||||||
if (user.status === 'stopped') continue;
|
|
||||||
if (matches(user)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
|
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
|
||||||
const initial = setTimeout(() => {
|
const initial = setTimeout(() => {
|
||||||
void sweepPasteImagesOnce(ctx);
|
void sweepPasteImagesOnce(ctx);
|
||||||
|
|||||||
+27
-191
@@ -567,14 +567,6 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
*/
|
*/
|
||||||
const SIDEBAR_RICH_CLOCK_MS = 20000;
|
const SIDEBAR_RICH_CLOCK_MS = 20000;
|
||||||
|
|
||||||
/**
|
|
||||||
* How long a `#session=<id>` link waits for the session list to name its id
|
|
||||||
* before the dashboard drops it with a "Session not found" toast (see
|
|
||||||
* _armUrlSessionWait). Long enough for a page that has just created the
|
|
||||||
* session to see its session:created land here.
|
|
||||||
*/
|
|
||||||
const URL_SESSION_WAIT_MS = 30000;
|
|
||||||
|
|
||||||
class CodemanApp {
|
class CodemanApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sessions = new Map();
|
this.sessions = new Map();
|
||||||
@@ -610,10 +602,6 @@ class CodemanApp {
|
|||||||
// service-worker shell loads), with the server-injected global as a fallback.
|
// service-worker shell loads), with the server-injected global as a fallback.
|
||||||
this.soloSessionId = this._detectSoloSessionId();
|
this.soloSessionId = this._detectSoloSessionId();
|
||||||
this.isSoloWindow = !!this.soloSessionId;
|
this.isSoloWindow = !!this.soloSessionId;
|
||||||
// A session another page asked for with a `#session=<id>` link. It waits
|
|
||||||
// here until the session list has that id (see _selectUrlSession).
|
|
||||||
this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();
|
|
||||||
this._urlSessionWaitTimer = null; // bounds that wait (_armUrlSessionWait)
|
|
||||||
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
|
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
|
||||||
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
|
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
|
||||||
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
|
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
|
||||||
@@ -1009,18 +997,6 @@ class CodemanApp {
|
|||||||
// strip never flashes before handleInit selects the target session.
|
// strip never flashes before handleInit selects the target session.
|
||||||
this._initWindowChannel();
|
this._initWindowChannel();
|
||||||
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
||||||
// A page holding this window switches its tab by changing only the
|
|
||||||
// fragment, which keeps the page loaded (see sessionIdFromFragment).
|
|
||||||
if (!this.isSoloWindow) {
|
|
||||||
window.addEventListener('hashchange', () => {
|
|
||||||
const id = this._takeUrlSession();
|
|
||||||
if (!id) return;
|
|
||||||
// A new link replaces one still waiting, and gets a wait of its own.
|
|
||||||
this._retireUrlSession();
|
|
||||||
this._urlSessionId = id;
|
|
||||||
this._selectUrlSession();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Initialize mobile handlers
|
// Initialize mobile handlers
|
||||||
KeyboardHandler.init();
|
KeyboardHandler.init();
|
||||||
SwipeHandler.init();
|
SwipeHandler.init();
|
||||||
@@ -1392,69 +1368,6 @@ class CodemanApp {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a `#session=<id>` link off the URL and drop the fragment. The next
|
|
||||||
* link to the same session is then a change the browser reports, even
|
|
||||||
* after you have clicked away to another tab. Returns the id or null. */
|
|
||||||
_takeUrlSession() {
|
|
||||||
const id = window.CodemanUrlSession?.sessionIdFromFragment(location.hash) ?? null;
|
|
||||||
if (id) {
|
|
||||||
try { history.replaceState(history.state, '', location.pathname + location.search); } catch {}
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show the session a `#session=<id>` link asked for, once the session list
|
|
||||||
* has it. A page that has just created a session can link to it before
|
|
||||||
* session:created arrives here, so an unknown id stays pending (for at most
|
|
||||||
* URL_SESSION_WAIT_MS) and _onSessionCreated tries again.
|
|
||||||
*
|
|
||||||
* ⚠️ The selection is `auto`. The page that set the fragment may be a
|
|
||||||
* script, and this window may not even be in front, so following a link is
|
|
||||||
* not a human looking at the session and must not spend its idle alert. */
|
|
||||||
_selectUrlSession() {
|
|
||||||
const id = this._urlSessionId;
|
|
||||||
if (!id) return false;
|
|
||||||
if (!this.sessions.has(id)) {
|
|
||||||
this._armUrlSessionWait(id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this._retireUrlSession();
|
|
||||||
this.selectSession(id, { auto: true });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bound the wait for a link whose id the session list does not have. A
|
|
||||||
* stale link (that session is closed), a typo, or in multi-user mode another
|
|
||||||
* user's session (never in this client's list) would otherwise wait with
|
|
||||||
* nothing on screen, and take the tab whenever a matching session turned up.
|
|
||||||
* One timer per link: handleInit running again (an SSE reconnect) does not
|
|
||||||
* restart it, and every way a link ends goes through _retireUrlSession. */
|
|
||||||
_armUrlSessionWait(id) {
|
|
||||||
if (this._urlSessionWaitTimer) return;
|
|
||||||
this._urlSessionWaitTimer = setTimeout(() => {
|
|
||||||
this._urlSessionWaitTimer = null;
|
|
||||||
if (this._urlSessionId !== id) return;
|
|
||||||
// Listed by a path other than session:created (a session:updated): select it.
|
|
||||||
if (this.sessions.has(id)) {
|
|
||||||
this._selectUrlSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._retireUrlSession();
|
|
||||||
this.showToast?.('Session not found', 'warning');
|
|
||||||
}, URL_SESSION_WAIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop a waiting `#session=<id>` link and its timer: the link was followed,
|
|
||||||
* replaced by a newer one, timed out, or the user chose something else
|
|
||||||
* (another tab, Home, a web tab). */
|
|
||||||
_retireUrlSession() {
|
|
||||||
this._urlSessionId = null;
|
|
||||||
if (this._urlSessionWaitTimer) {
|
|
||||||
clearTimeout(this._urlSessionWaitTimer);
|
|
||||||
this._urlSessionWaitTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pop a session out into its own browser window. SINGLE, idempotent entry
|
* Pop a session out into its own browser window. SINGLE, idempotent entry
|
||||||
* point: the tab's pop-out icon calls this, and a future gesture layer
|
* point: the tab's pop-out icon calls this, and a future gesture layer
|
||||||
@@ -2036,7 +1949,6 @@ class CodemanApp {
|
|||||||
this.updateCost();
|
this.updateCost();
|
||||||
// Start stats polling when first session appears
|
// Start stats polling when first session appears
|
||||||
if (this.sessions.size === 1) this.startSystemStatsPolling();
|
if (this.sessions.size === 1) this.startSystemStatsPolling();
|
||||||
if (this._urlSessionId === data.id) this._selectUrlSession();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onSessionUpdated(data) {
|
_onSessionUpdated(data) {
|
||||||
@@ -2343,18 +2255,13 @@ class CodemanApp {
|
|||||||
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
|
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||||
* Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable.
|
_renderMarkdown(text) {
|
||||||
* `breaks` turns every source newline into a <br>: right for chat, where a
|
|
||||||
* newline is the agent's line break, wrong for a file (the File Viewer passes
|
|
||||||
* false), where a README hard-wrapped at 80 columns would break at every wrap.
|
|
||||||
*/
|
|
||||||
_renderMarkdown(text, { breaks = true } = {}) {
|
|
||||||
const src = text || '';
|
const src = text || '';
|
||||||
if (typeof marked !== 'undefined' && marked.parse) {
|
if (typeof marked !== 'undefined' && marked.parse) {
|
||||||
try {
|
try {
|
||||||
const prepared = this._preprocessAsciiArt(src);
|
const prepared = this._preprocessAsciiArt(src);
|
||||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks, gfm: true }));
|
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
||||||
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
||||||
// on mobile without collapsing into block-level cells.
|
// on mobile without collapsing into block-level cells.
|
||||||
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
|
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
|
||||||
@@ -2451,9 +2358,7 @@ class CodemanApp {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const filePath = pathLink.dataset.path;
|
const filePath = pathLink.dataset.path;
|
||||||
// A rendered document's links name the session the preview was opened
|
if (filePath) this.openFilePreview(filePath, this.activeSessionId);
|
||||||
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
|
|
||||||
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4462,16 +4367,6 @@ class CodemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A `#session=<id>` link wins over restoring the last active tab.
|
|
||||||
if (this._urlSessionId && this.sessions.has(this._urlSessionId)) {
|
|
||||||
this.activeSessionId = null;
|
|
||||||
this._selectUrlSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Not listed yet: its wait starts now that the list has loaded, and the
|
|
||||||
// last active tab is restored meanwhile.
|
|
||||||
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
|
|
||||||
|
|
||||||
const previousActiveId = this.activeSessionId;
|
const previousActiveId = this.activeSessionId;
|
||||||
if (this.sessionOrder.length === 0) {
|
if (this.sessionOrder.length === 0) {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
@@ -5023,10 +4918,9 @@ class CodemanApp {
|
|||||||
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
|
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
|
||||||
// or busy for an exited pane by design, so without this the muted dot sits
|
// or busy for an exited pane by design, so without this the muted dot sits
|
||||||
// beside a pill saying "idle". A pending alert still wins, exactly as it
|
// beside a pill saying "idle". A pending alert still wins, exactly as it
|
||||||
// does for the dot. The rule is `_mobileOverviewExit()`, shared with both
|
// does for the dot.
|
||||||
// home screens so the three surfaces agree on which sessions have exited.
|
const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working');
|
||||||
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
|
const exitAt = exited ? Number(session.paneExit.at) || 0 : 0;
|
||||||
const exited = !!exit;
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
exited,
|
exited,
|
||||||
@@ -5037,7 +4931,13 @@ class CodemanApp {
|
|||||||
// state pill and never replaces it.
|
// state pill and never replaces it.
|
||||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||||
createdAt: Number(session.createdAt) || 0,
|
createdAt: Number(session.createdAt) || 0,
|
||||||
since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
|
since: exitAt
|
||||||
|
? { key: 'exited', at: exitAt }
|
||||||
|
: exited
|
||||||
|
? null
|
||||||
|
: this._mobileOverviewSince
|
||||||
|
? this._mobileOverviewSince(state, session)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6425,15 +6325,10 @@ class CodemanApp {
|
|||||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||||
if (this.detachedSessions?.has(sessionId)) return;
|
if (this.detachedSessions?.has(sessionId)) return;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
// A shell's full capture can be many megabytes, and replaying all of it from
|
// A shell's full capture can be many megabytes. Replaying it from an
|
||||||
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
|
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
||||||
// pulls a BOUNDED window of tmux's full history (the same 1 MiB a tab switch
|
// behind the explicit "Load full history" button.
|
||||||
// loads, but of the scrollback rather than the visible frame) and the
|
if (!force && session?.mode === 'shell') return;
|
||||||
// unbounded pull stays behind the "Load full history" button. Declining
|
|
||||||
// outright left a shell pane about one screen of browser scrollback after any
|
|
||||||
// burst, and the button only renders once a replay was truncated, so a young
|
|
||||||
// shell tab had no way back to output tmux was still holding.
|
|
||||||
const boundedShellPull = !force && session?.mode === 'shell';
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||||
@@ -6446,12 +6341,7 @@ class CodemanApp {
|
|||||||
this._fullHistoryRepullInFlight = true;
|
this._fullHistoryRepullInFlight = true;
|
||||||
try {
|
try {
|
||||||
const requestStartedAt = performance.now();
|
const requestStartedAt = performance.now();
|
||||||
const capture = await this._fetchTerminalCapture(
|
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||||
boundedShellPull
|
|
||||||
? `/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`
|
|
||||||
: `/api/sessions/${sessionId}/terminal?full=1`,
|
|
||||||
{ full: true }
|
|
||||||
);
|
|
||||||
const headersReceivedAt = capture.headersAt;
|
const headersReceivedAt = capture.headersAt;
|
||||||
const payload = capture.json?.data ?? {};
|
const payload = capture.json?.data ?? {};
|
||||||
const bodyParsedAt = performance.now();
|
const bodyParsedAt = performance.now();
|
||||||
@@ -6472,44 +6362,7 @@ class CodemanApp {
|
|||||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||||
// history into the terminal the user is now looking at.
|
// history into the terminal the user is now looking at.
|
||||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||||
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
|
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||||
// A bounded window no longer than the browser's buffer buys nothing, and
|
|
||||||
// resetting to rewrite it would jump the viewport on every scroll that
|
|
||||||
// outlasts the cooldown at the top. This runs BEFORE the downgrade guard
|
|
||||||
// on purpose: that guard reads "smaller than the browser" as "tmux has
|
|
||||||
// nothing more to give", which is true of an unbounded capture but not of a
|
|
||||||
// window cut at the tail size, so a bounded window must never reach the
|
|
||||||
// exhausted path, which would take Load full history off the banner while
|
|
||||||
// tmux still holds the rest. Nothing was written here, so the banner state
|
|
||||||
// is left as the load that produced it set it: re-labelling it from this
|
|
||||||
// payload would call a terminal that holds ALL of a Load full history pull
|
|
||||||
// "the most recent 1 MiB".
|
|
||||||
//
|
|
||||||
// A browser already at xterm's cap buys nothing either. xterm keeps at most
|
|
||||||
// `scrollback + rows` rows (DEFAULT_SCROLLBACK 50k) while tmux keeps 100k
|
|
||||||
// lines by default, so a 1 MiB window of short lines can render to more rows
|
|
||||||
// than the browser can ever hold, and `windowRows <= rowsNow` then never
|
|
||||||
// comes true: without this every scroll-to-top would reset and re-parse it.
|
|
||||||
const rowsNow = this.terminal.buffer.active.length;
|
|
||||||
const scrollbackCap = this.terminal.options?.scrollback || 0;
|
|
||||||
const browserFull = scrollbackCap > 0 && rowsNow >= scrollbackCap + this.terminal.rows;
|
|
||||||
if (boundedShellPull && (windowRows <= rowsNow || browserFull)) {
|
|
||||||
// An untruncated window IS all of tmux's history, so nothing is missing,
|
|
||||||
// and the next burst of output can put more in tmux than the browser has:
|
|
||||||
// keep the normal 4 s cooldown. A truncated one is the opposite case, since
|
|
||||||
// the gesture can never reach anything older than what the browser already
|
|
||||||
// shows, and every ask costs the server a synchronous capture-pane of the
|
|
||||||
// whole history (`tail` is applied after the capture): back off to 60 s.
|
|
||||||
// A full browser backs off too, since no window can ever fit in it.
|
|
||||||
// Trade-off: only a successful replay clears that latch, so a tab switch or
|
|
||||||
// burst that shrinks the browser's buffer below the window can leave a
|
|
||||||
// scroll-to-top inert for up to a minute. Load full history (`force`)
|
|
||||||
// bypasses the cooldown, and the latch is bounded, never permanent.
|
|
||||||
if (payload.truncated || browserFull) (this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
|
||||||
this._logScrollRouting?.('repull-skipped-bounded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._replayWouldShrinkBuffer(buffer, windowRows)) {
|
|
||||||
timing.refused = true;
|
timing.refused = true;
|
||||||
timing.totalMs = performance.now() - requestStartedAt;
|
timing.totalMs = performance.now() - requestStartedAt;
|
||||||
this._recordTerminalLoadTiming(timing);
|
this._recordTerminalLoadTiming(timing);
|
||||||
@@ -6520,14 +6373,7 @@ class CodemanApp {
|
|||||||
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
|
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A bounded window that was cut is always recoverable: a capture over the
|
this._setHistoryTruncation(sessionId, payload);
|
||||||
// byte cap keeps `truncationReason: 'capped'` through the tail cut, and that
|
|
||||||
// would tell the user the rest "cannot be recovered" and drop Load full
|
|
||||||
// history, whose unbounded pull returns up to the cap itself.
|
|
||||||
this._setHistoryTruncation(
|
|
||||||
sessionId,
|
|
||||||
boundedShellPull && payload.truncated ? { ...payload, truncationReason: 'tail' } : payload
|
|
||||||
);
|
|
||||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||||
const rowsBefore = this.terminal.buffer.active.length;
|
const rowsBefore = this.terminal.buffer.active.length;
|
||||||
const replayStartedAt = performance.now();
|
const replayStartedAt = performance.now();
|
||||||
@@ -6665,12 +6511,6 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectSession(sessionId, options = {}) {
|
async selectSession(sessionId, options = {}) {
|
||||||
// Picking another tab yourself retires a `#session=<id>` link still
|
|
||||||
// waiting for its session, which would otherwise take the tab from you
|
|
||||||
// whenever that session turned up (see _selectUrlSession).
|
|
||||||
if (options?.auto !== true && this._urlSessionId && this._urlSessionId !== sessionId) {
|
|
||||||
this._retireUrlSession();
|
|
||||||
}
|
|
||||||
// If this session is popped out into its own window, raise that window
|
// If this session is popped out into its own window, raise that window
|
||||||
// instead of showing it inline (focus-on-click for detached tabs). If we
|
// instead of showing it inline (focus-on-click for detached tabs). If we
|
||||||
// owned a now-closed window, _raiseDetached re-docks and returns false so
|
// owned a now-closed window, _raiseDetached re-docks and returns false so
|
||||||
@@ -6680,13 +6520,12 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
const forceReload = options?.forceReload === true;
|
const forceReload = options?.forceReload === true;
|
||||||
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
|
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
|
||||||
// the boot restore, a solo window opening its target, a `#session=<id>`
|
// the boot restore, a solo window opening its target, the fallback after
|
||||||
// link from another page, the fallback after the active session is
|
// the active session is deleted. Those must NOT spend a pending idle alert
|
||||||
// deleted. Those must NOT spend a pending idle alert (the yellow survives
|
// (the yellow survives until a real tap), because "the app put this on
|
||||||
// until a real tap), because "the app put this on screen" is not "I
|
// screen" is not "I checked it". The DEFAULT is user-initiated, so a call
|
||||||
// checked it". The DEFAULT is user-initiated, so a call site nobody tagged
|
// site nobody tagged fails toward acknowledging rather than toward an
|
||||||
// fails toward acknowledging rather than toward an alert that can never be
|
// alert that can never be cleared.
|
||||||
// cleared.
|
|
||||||
const userInitiated = options?.auto !== true;
|
const userInitiated = options?.auto !== true;
|
||||||
if (this.activeSessionId === sessionId && !forceReload) {
|
if (this.activeSessionId === sessionId && !forceReload) {
|
||||||
// Tapping the tab you are already on is still "I checked it". The alert
|
// Tapping the tab you are already on is still "I checked it". The alert
|
||||||
@@ -7649,9 +7488,6 @@ class CodemanApp {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
goHome() {
|
goHome() {
|
||||||
// Going Home is choosing something else, so a `#session=<id>` link still
|
|
||||||
// waiting for its session must not take the screen later.
|
|
||||||
this._retireUrlSession();
|
|
||||||
// Deselect active session and show welcome screen
|
// Deselect active session and show welcome screen
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||||
|
|||||||
@@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) {
|
|||||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||||
*/
|
*/
|
||||||
const FILE_PATH_LINK_PATTERN =
|
const FILE_PATH_LINK_PATTERN =
|
||||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
|
||||||
|
|
||||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||||
function absoluteFilePathPattern() {
|
function absoluteFilePathPattern() {
|
||||||
@@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() {
|
|||||||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||||
*/
|
*/
|
||||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||||
@@ -1853,27 +1853,10 @@ function reconcilePtyGeometry(local, pty) {
|
|||||||
return { adopt: true, cols: pty.cols };
|
return { adopt: true, cols: pty.cols };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Which session does a dashboard URL's fragment ask for? Another page that
|
|
||||||
* holds the dashboard's window, such as a task board, points it at
|
|
||||||
* `/#session=<id>`. Only the fragment changes between two such links, so the
|
|
||||||
* browser keeps the page loaded and fires `hashchange`, and the dashboard
|
|
||||||
* switches tabs without reloading. Any other fragment asks for nothing.
|
|
||||||
*
|
|
||||||
* @param {string} hash - `location.hash`, with or without its leading `#`
|
|
||||||
* @returns {string|null} the session id, or null
|
|
||||||
*/
|
|
||||||
function sessionIdFromFragment(hash) {
|
|
||||||
const params = new URLSearchParams(String(hash || '').replace(/^#/, ''));
|
|
||||||
const id = params.get('session');
|
|
||||||
return id && id.trim() ? id.trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||||
window.CodemanUrlSession = { sessionIdFromFragment };
|
|
||||||
window.CodemanSplitPane = {
|
window.CodemanSplitPane = {
|
||||||
clampDividerPercent,
|
clampDividerPercent,
|
||||||
buildSplitPickerSessions,
|
buildSplitPickerSessions,
|
||||||
|
|||||||
@@ -201,8 +201,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||||
// Guarded: a stale cached mobile-overview.js may predate the helper.
|
|
||||||
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
|
|
||||||
const mode = session.mode || 'claude';
|
const mode = session.mode || 'claude';
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -213,10 +211,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
caseName: matched ? matched.name : '',
|
caseName: matched ? matched.name : '',
|
||||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||||
state,
|
state,
|
||||||
// What the row's dot, accent and pill show. It differs from `state` only
|
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||||
// for an exited agent (Ark0N/Codeman#446), whose state still sorts it.
|
|
||||||
display: exit ? 'exited' : state,
|
|
||||||
pill: exit ? 'exited' : HOME_SESSIONS_PILL_LABEL[state] || state,
|
|
||||||
// What the pane's footer says is still running in the background, straight off
|
// What the pane's footer says is still running in the background, straight off
|
||||||
// the session payload. Same field, same meaning as on the phone overview.
|
// the session payload. Same field, same meaning as on the phone overview.
|
||||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||||
@@ -229,7 +224,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
// "how long has it been like this", resolved by the phone overview's
|
// "how long has it been like this", resolved by the phone overview's
|
||||||
// helper so both home screens label the same stamp with the same word.
|
// helper so both home screens label the same stamp with the same word.
|
||||||
since: exit ? exit.since : this._mobileOverviewSince(state, session),
|
since: this._mobileOverviewSince(state, session),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,8 +392,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_buildHomeSessionRow(row) {
|
_buildHomeSessionRow(row) {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.type = 'button';
|
item.type = 'button';
|
||||||
const display = row.display || row.state;
|
item.className = 'home-sessions-row home-sessions-row--' + row.state;
|
||||||
item.className = 'home-sessions-row home-sessions-row--' + display;
|
|
||||||
item.dataset.hsAction = 'session';
|
item.dataset.hsAction = 'session';
|
||||||
item.dataset.hsSession = row.id;
|
item.dataset.hsSession = row.id;
|
||||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||||
@@ -415,7 +409,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dot = document.createElement('span');
|
const dot = document.createElement('span');
|
||||||
dot.className = 'home-sessions-dot home-sessions-dot--' + display;
|
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
|
||||||
dot.setAttribute('aria-hidden', 'true');
|
dot.setAttribute('aria-hidden', 'true');
|
||||||
item.appendChild(dot);
|
item.appendChild(dot);
|
||||||
|
|
||||||
@@ -447,7 +441,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
item.appendChild(body);
|
item.appendChild(body);
|
||||||
|
|
||||||
const pill = document.createElement('span');
|
const pill = document.createElement('span');
|
||||||
pill.className = 'home-sessions-pill home-sessions-pill--' + display;
|
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
|
||||||
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
|
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
|
||||||
// that collide with state strings on other surfaces.
|
// that collide with state strings on other surfaces.
|
||||||
pill.setAttribute('data-i18n-skip', '');
|
pill.setAttribute('data-i18n-skip', '');
|
||||||
|
|||||||
@@ -571,8 +571,6 @@
|
|||||||
'Task Complete': '任务完成',
|
'Task Complete': '任务完成',
|
||||||
'Copied to clipboard': '已复制到剪贴板',
|
'Copied to clipboard': '已复制到剪贴板',
|
||||||
'Nothing to copy': '没有可复制的内容',
|
'Nothing to copy': '没有可复制的内容',
|
||||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
|
||||||
'Session not found': '未找到会话',
|
|
||||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||||
Copy: '复制',
|
Copy: '复制',
|
||||||
@@ -729,9 +727,6 @@
|
|||||||
'Source type filter': '来源类型筛选',
|
'Source type filter': '来源类型筛选',
|
||||||
'Copy content': '复制内容',
|
'Copy content': '复制内容',
|
||||||
'Edit file': '编辑文件',
|
'Edit file': '编辑文件',
|
||||||
'Rendered markdown': '渲染 Markdown',
|
|
||||||
'Line numbers': '行号',
|
|
||||||
'Wrap lines': '自动换行',
|
|
||||||
'Unsaved changes': '未保存的更改',
|
'Unsaved changes': '未保存的更改',
|
||||||
Saved: '已保存',
|
Saved: '已保存',
|
||||||
'Export as JSON': '导出为 JSON',
|
'Export as JSON': '导出为 JSON',
|
||||||
|
|||||||
@@ -573,9 +573,6 @@
|
|||||||
<div class="file-preview-header">
|
<div class="file-preview-header">
|
||||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||||
<div class="file-preview-actions">
|
<div class="file-preview-actions">
|
||||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
|
||||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
|
||||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
|
||||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||||
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
||||||
@@ -3178,7 +3175,6 @@
|
|||||||
<h2>Manage</h2>
|
<h2>Manage</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
|
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
|
||||||
<input type="search" id="caseManageSearch" class="set-input" placeholder="Search cases by name or path" autocomplete="off" spellcheck="false" aria-label="Search cases" oninput="app.setCaseManageFilter(this.value)" style="margin-bottom: 8px;">
|
|
||||||
<div class="case-manage-list" id="caseManageList">
|
<div class="case-manage-list" id="caseManageList">
|
||||||
<!-- Populated by JS -->
|
<!-- Populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -161,36 +161,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* The exited-agent override for one row (Ark0N/Codeman#446), or null when
|
|
||||||
* the row shows its state as usual.
|
|
||||||
*
|
|
||||||
* The server publishes `session.paneExit` once the agent inside a local tmux
|
|
||||||
* pane has exited, while `status` stays `idle` or `busy` by design. So a row
|
|
||||||
* classified as idle or working may really be a pane with nothing running
|
|
||||||
* in it. This overrides what the row SHOWS, never its `state`: `state` still
|
|
||||||
* picks the section and the sort, the way `_sidebarRichRow()` (app.js) does
|
|
||||||
* for the detailed sidebar and rail. A pending alert still wins, because a
|
|
||||||
* human being blocked outranks the agent having exited.
|
|
||||||
*
|
|
||||||
* Shared by the phone overview, the desktop home rail and the rich tab rows,
|
|
||||||
* so the three cannot disagree about which sessions have exited.
|
|
||||||
*
|
|
||||||
* Guarded like every other cross-file call: `paneExitLabel()` lives in
|
|
||||||
* app.js, and a stale cached app.js must degrade to no override, not throw.
|
|
||||||
*
|
|
||||||
* @returns {{since: {key: string, at: number}|null}|null}
|
|
||||||
*/
|
|
||||||
_mobileOverviewExit(state, session) {
|
|
||||||
if (state !== 'idle' && state !== 'working') return null;
|
|
||||||
if (typeof paneExitLabel !== 'function' || !paneExitLabel(session.paneExit)) return null;
|
|
||||||
// `at` is when this server first saw the pane dead, which is what "exited
|
|
||||||
// 2m" should measure. A row without it shows no duration at all rather
|
|
||||||
// than a working or idle stamp that no longer describes the pane.
|
|
||||||
const at = Number(session.paneExit.at) || 0;
|
|
||||||
return { since: at ? { key: 'exited', at } : null };
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Longest-prefix match of a workingDir against the case list, so a session
|
* 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
|
* started in a subdirectory still belongs to its case. Mirrors the matching in
|
||||||
@@ -232,7 +202,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const rows = sessions.map((session) => {
|
const rows = sessions.map((session) => {
|
||||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||||
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
|
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
|
||||||
const exit = this._mobileOverviewExit(state, session);
|
|
||||||
const orderIndex = order.indexOf(session.id);
|
const orderIndex = order.indexOf(session.id);
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
@@ -241,10 +210,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
caseName: matched ? matched.name : '',
|
caseName: matched ? matched.name : '',
|
||||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||||
state,
|
state,
|
||||||
// What the row's dot, accent and pill show. It differs from `state` only
|
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||||
// for an exited agent, whose state still decides the section and sort.
|
|
||||||
display: exit ? 'exited' : state,
|
|
||||||
pill: exit ? 'exited' : MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
|
||||||
// What the pane's own footer says is still running in the background ("1 monitor",
|
// What the pane's own footer says is still running in the background ("1 monitor",
|
||||||
// "2 shells"), straight off the session payload. A row that has one is quiet
|
// "2 shells"), straight off the session payload. A row that has one is quiet
|
||||||
// because the agent is waiting for that, not because it is waiting for you.
|
// because the agent is waiting for that, not because it is waiting for you.
|
||||||
@@ -256,7 +222,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// pair resolved for DISPLAY, and the two must not drift apart.
|
// pair resolved for DISPLAY, and the two must not drift apart.
|
||||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||||
since: exit ? exit.since : this._mobileOverviewSince(state, session),
|
since: this._mobileOverviewSince(state, session),
|
||||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -732,13 +698,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_buildMobileOverviewRow(row) {
|
_buildMobileOverviewRow(row) {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.type = 'button';
|
item.type = 'button';
|
||||||
const display = row.display || row.state;
|
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
|
||||||
item.className = 'mobile-overview-row mobile-overview-row--' + display;
|
|
||||||
item.dataset.moAction = 'session';
|
item.dataset.moAction = 'session';
|
||||||
item.dataset.moSession = row.id;
|
item.dataset.moSession = row.id;
|
||||||
|
|
||||||
const dot = document.createElement('span');
|
const dot = document.createElement('span');
|
||||||
dot.className = 'mobile-overview-dot mobile-overview-dot--' + display;
|
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
|
||||||
dot.setAttribute('aria-hidden', 'true');
|
dot.setAttribute('aria-hidden', 'true');
|
||||||
item.appendChild(dot);
|
item.appendChild(dot);
|
||||||
|
|
||||||
@@ -771,7 +736,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
item.appendChild(body);
|
item.appendChild(body);
|
||||||
|
|
||||||
const pill = document.createElement('span');
|
const pill = document.createElement('span');
|
||||||
pill.className = 'mobile-overview-pill mobile-overview-pill--' + display;
|
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
|
||||||
// Skipped by i18n on purpose: the labels are generic single words ("idle",
|
// Skipped by i18n on purpose: the labels are generic single words ("idle",
|
||||||
// "done", "error") that collide with state strings on other surfaces.
|
// "done", "error") that collide with state strings on other surfaces.
|
||||||
pill.setAttribute('data-i18n-skip', '');
|
pill.setAttribute('data-i18n-skip', '');
|
||||||
|
|||||||
+16
-124
@@ -332,39 +332,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Edge fade for the phone's header tab strip (used in the block below).
|
|
||||||
Registered so the keyframes can interpolate them as lengths; @property is
|
|
||||||
only valid at the top level, hence out here. */
|
|
||||||
@property --tab-strip-fade-start {
|
|
||||||
syntax: '<length>';
|
|
||||||
inherits: false;
|
|
||||||
initial-value: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property --tab-strip-fade-end {
|
|
||||||
syntax: '<length>';
|
|
||||||
inherits: false;
|
|
||||||
initial-value: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Driven by the strip's own scroll position, not by time: at the start only the
|
|
||||||
end edge fades, at the end only the start edge, and in between both. */
|
|
||||||
@keyframes tab-strip-edge-fade {
|
|
||||||
0% {
|
|
||||||
--tab-strip-fade-start: 0px;
|
|
||||||
--tab-strip-fade-end: 28px;
|
|
||||||
}
|
|
||||||
10%,
|
|
||||||
90% {
|
|
||||||
--tab-strip-fade-start: 28px;
|
|
||||||
--tab-strip-fade-end: 28px;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
--tab-strip-fade-start: 28px;
|
|
||||||
--tab-strip-fade-end: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Phone Breakpoint (<600px)
|
Phone Breakpoint (<600px)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -685,7 +652,7 @@ html.mobile-init .file-browser-panel {
|
|||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
max-height: 36px;
|
max-height: 36px;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,36 +660,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fade the strip's edges while there is more to scroll to, so the tab that
|
|
||||||
does not fit dissolves into the edge instead of being cut mid-word against
|
|
||||||
the connection dot. Scroll-driven, no JS: the timeline is the strip's own
|
|
||||||
inline scroll (keyframes + registered properties above this block). A strip
|
|
||||||
that does not overflow has an INACTIVE timeline, so the animation applies
|
|
||||||
nothing and both widths stay at their registered 0px, which is no mask at
|
|
||||||
all. Browsers without scroll timelines skip the block and keep the hard
|
|
||||||
edge. Header only: in sidebar layout the same list scrolls vertically. */
|
|
||||||
@supports (animation-timeline: scroll()) {
|
|
||||||
.header .session-tabs {
|
|
||||||
-webkit-mask-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
transparent,
|
|
||||||
#000 var(--tab-strip-fade-start),
|
|
||||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
transparent,
|
|
||||||
#000 var(--tab-strip-fade-start),
|
|
||||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
/* The shorthand resets animation-timeline, so the timeline comes after. */
|
|
||||||
animation: tab-strip-edge-fade linear both;
|
|
||||||
animation-timeline: scroll(self inline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smaller tabs for mobile */
|
/* Smaller tabs for mobile */
|
||||||
.session-tab {
|
.session-tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -734,46 +671,10 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Every tab in the header strip is a chip, not only the active one. Left
|
/* Smaller status indicator on mobile */
|
||||||
transparent, the strip read as a row of disabled labels: grey 11px text
|
|
||||||
floating in unmarked gaps, with nothing saying "tap me". Fill and border
|
|
||||||
come from the skin's control tokens, so the four light skins (which repaint
|
|
||||||
the header with --glass-bg) get a matching chip with no override block, and
|
|
||||||
the active tab's !important fill and border in styles.css still win.
|
|
||||||
`:where(.header)` keeps this at (0,1,0): the per-colour left border
|
|
||||||
(`.session-tab[data-color="red"]`, (0,2,0)) must still outrank the
|
|
||||||
border-color here, and in sidebar layout the list leaves the header, so
|
|
||||||
its rows are untouched. */
|
|
||||||
:where(.header) .session-tab {
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--control-bg-hover);
|
|
||||||
border-color: var(--control-border-hover);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(.header) .session-tab .tab-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
|
||||||
every other tab the container is empty but still a flex item, and its gap
|
|
||||||
made the chip visibly wider on the right than on the left. */
|
|
||||||
:where(.header) .session-tab:not(.active) .tab-actions {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The boxed digit is the Alt+1..9 shortcut hint. A phone has no Alt key, so
|
|
||||||
here it was only a second grey box inside every tab, and 20px of the name's
|
|
||||||
width. Every header tab is therefore numberless on a phone, which is the
|
|
||||||
case the active-tab reserve below is already sized for. */
|
|
||||||
:where(.header) .session-tab .tab-number {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status dot: 6px so an idle green reads at arm's length (4px was a speck). */
|
|
||||||
.session-tab .tab-status {
|
.session-tab .tab-status {
|
||||||
width: 6px;
|
width: 4px;
|
||||||
height: 6px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
||||||
@@ -809,12 +710,9 @@ html.mobile-init .file-browser-panel {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Truncate tab names on mobile. 80px, not the old 50px: session names share
|
/* Truncate tab names more aggressively on mobile */
|
||||||
a `w1-` style prefix, and at 50px "w1-ingest-pipeline" became "w1-inge…"
|
|
||||||
and a clipped tab just "w1-", which says nothing about which session it is.
|
|
||||||
The 20px the hidden tab number gave back pays for most of the difference. */
|
|
||||||
.session-tab .tab-name {
|
.session-tab .tab-name {
|
||||||
max-width: 80px;
|
max-width: 50px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@@ -828,19 +726,20 @@ html.mobile-init .file-browser-panel {
|
|||||||
difference instead, which costs a little strip space on exactly one tab
|
difference instead, which costs a little strip space on exactly one tab
|
||||||
and keeps tap-to-switch the majority of it.
|
and keeps tap-to-switch the majority of it.
|
||||||
|
|
||||||
⚠️ The floor is set by a NUMBERLESS tab. `.tab-number` is rendered only
|
⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you
|
||||||
for `_tabIdx < 9` (app.js), and the header hides it on phones altogether
|
are looking at. `.tab-number` is rendered only for `_tabIdx < 9` (app.js),
|
||||||
(above), so every phone tab is that case now; a numbered one would sit 10px
|
so tab 10 loses 16px + a 4px gap off its left and its centre sits 10px
|
||||||
further left and hide the problem. The centre clears the icons when
|
further right. The centre clears the icons when
|
||||||
|
|
||||||
reserved > icons + rightEdge - leftRunUp - gap
|
reserved > icons + rightEdge - leftRunUp - gap
|
||||||
= 50 + 9 - 19 - 4 = 36px
|
= 50 + 9 - 17 - 4 = 38px
|
||||||
|
|
||||||
with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1
|
with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1
|
||||||
+ padding 8 + status dot 6 + gap 4, and rightEdge = padding 8 + border 1.
|
+ padding 8 + status dot 4 + gap 4, and rightEdge = padding 8 + border 1.
|
||||||
Hit testing snaps to whole pixels, so a centre half a pixel short still
|
Hit testing snaps to whole pixels, so 39px still lands on the gear: the
|
||||||
lands on the gear: the practical floor was measured at 40px (with the
|
practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab
|
||||||
older 4px dot) and 44px keeps headroom. Pinned by
|
clears it at 20px, so reasoning from the tabs on screen is exactly what
|
||||||
|
would put the centre back on the gear. Pinned by
|
||||||
test/mobile-tab-tap-zones.test.ts. */
|
test/mobile-tab-tap-zones.test.ts. */
|
||||||
.session-tab.active .tab-name {
|
.session-tab.active .tab-name {
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
@@ -3081,13 +2980,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An exited agent (Ark0N/Codeman#446): neutral, since nothing is running behind
|
|
||||||
the row. The dot and the row take `--exited` too and keep their base rules. */
|
|
||||||
.mobile-overview-pill--exited {
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accent, and none of the three above: a session watching work it started itself
|
/* Accent, and none of the three above: a session watching work it started itself
|
||||||
is not asking the user for anything, and red and yellow are what say it is. */
|
is not asking the user for anything, and red and yellow are what say it is. */
|
||||||
.mobile-overview-pill--watching {
|
.mobile-overview-pill--watching {
|
||||||
|
|||||||
+10
-320
@@ -20,20 +20,6 @@ const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
|
|||||||
// a huge log is a partial read rather than a download the viewer throws away.
|
// a huge log is a partial read rather than a download the viewer throws away.
|
||||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
|
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
|
||||||
const TEXT_PREVIEW_MAX_LINES = 500;
|
const TEXT_PREVIEW_MAX_LINES = 500;
|
||||||
// A markdown document gets the route's ceiling instead of the 500-line preview
|
|
||||||
// cap: a rendered README cut mid-way reads as the whole document.
|
|
||||||
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
|
|
||||||
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
|
||||||
// File Viewer text-view prefs: per-device, in their own localStorage keys for
|
|
||||||
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
|
|
||||||
// rebuilt from the settings modal on save, so a key toggled from the viewer
|
|
||||||
// would be dropped on the next save).
|
|
||||||
const FILE_PREVIEW_PREF_KEYS = {
|
|
||||||
mdRendered: 'codeman:filePreviewMdRendered',
|
|
||||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
|
||||||
wrap: 'codeman:filePreviewWrap',
|
|
||||||
};
|
|
||||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
|
|
||||||
const AWAY_DIGEST_SECTIONS = [
|
const AWAY_DIGEST_SECTIONS = [
|
||||||
['needsAttention', 'Needs Attention'],
|
['needsAttention', 'Needs Attention'],
|
||||||
['completed', 'Completed'],
|
['completed', 'Completed'],
|
||||||
@@ -353,67 +339,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Save what had focus before a search-first overlay takes it.
|
|
||||||
*
|
|
||||||
* The Command Palette and the Session Manager both call `search.focus()` on
|
|
||||||
* open, and both used to close by removing the `active` class and nothing
|
|
||||||
* else. Hiding the focused input does not hand focus back to anyone — the
|
|
||||||
* browser drops it on `<body>` — so after Escape closed the overlay every
|
|
||||||
* keystroke went nowhere and the user had to click the terminal to type
|
|
||||||
* again (measured: `document.activeElement` is BODY afterwards and the
|
|
||||||
* terminal emits no onData at all). Every close path has the same hole, so
|
|
||||||
* the restore lives in the close functions, not in the global Escape chain.
|
|
||||||
*
|
|
||||||
* ⚠️ Deliberately only the save/restore half of {@link FocusTrap}, not the
|
|
||||||
* whole thing. `FocusTrap.activate()` moves focus to the first focusable
|
|
||||||
* element, which in both of these overlays is not the search box — adopting
|
|
||||||
* it wholesale would fix the focus loss by breaking the thing Cmd+K exists
|
|
||||||
* for, typing a filter the moment it opens.
|
|
||||||
*/
|
|
||||||
_rememberOverlayFocus(key) {
|
|
||||||
this[key] = (typeof document !== 'undefined' && document.activeElement) || null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hand focus back to whatever {@link _rememberOverlayFocus} saved.
|
|
||||||
*
|
|
||||||
* ⚠️ The terminal fallback is gated on there being an active session: an
|
|
||||||
* overlay opened from the welcome screen has no terminal to return to, and
|
|
||||||
* focusing one on a phone summons the on-screen keyboard over a screen that
|
|
||||||
* has no input on it.
|
|
||||||
*
|
|
||||||
* ⚠️ A focus that already left the overlay is kept, not overridden. The
|
|
||||||
* Session Manager's row menu ("Switch to session", "Open folder") calls
|
|
||||||
* selectSession(), which focuses the terminal, BEFORE closeSessionManager();
|
|
||||||
* restoring there would pull focus back to the header button that opened the
|
|
||||||
* modal. Only a focus still inside `modal`, or one dropped on `<body>`, is
|
|
||||||
* the overlay's to hand back.
|
|
||||||
*/
|
|
||||||
_restoreOverlayFocus(key, modal) {
|
|
||||||
const prev = this[key];
|
|
||||||
this[key] = null;
|
|
||||||
const body = typeof document !== 'undefined' ? document.body : null;
|
|
||||||
const current = typeof document !== 'undefined' ? document.activeElement : null;
|
|
||||||
if (current && current !== body && modal?.contains?.(current) === false) return;
|
|
||||||
// `isConnected === false` means the element was removed while the overlay
|
|
||||||
// was open (a re-render of the tab strip, say); anything else — including
|
|
||||||
// a stub with no such property — is treated as still focusable.
|
|
||||||
if (prev && prev !== body && prev.isConnected !== false && typeof prev.focus === 'function') {
|
|
||||||
prev.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ⚠️ `activeSessionId` alone only covers the welcome screen. On a touch device
|
|
||||||
// with the keyboard down, focus sits on `<body>`, so focusing the terminal here
|
|
||||||
// would summon the on-screen keyboard — `selectSession()` deliberately skips the
|
|
||||||
// focus for exactly that reason, and this would override it. The app's own
|
|
||||||
// predicate already encodes the rule (true on desktop, on touch only while the
|
|
||||||
// keyboard is open); the optional call keeps the vm test harness working.
|
|
||||||
if (this.activeSessionId && this._shouldFocusTerminalForTabSwitch?.() !== false) {
|
|
||||||
this.terminal?.focus?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
openCommandPalette() {
|
openCommandPalette() {
|
||||||
const modal = document.getElementById('commandPaletteModal');
|
const modal = document.getElementById('commandPaletteModal');
|
||||||
const search = document.getElementById('commandPaletteSearch');
|
const search = document.getElementById('commandPaletteSearch');
|
||||||
@@ -426,25 +351,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._wireCommandPalette();
|
this._wireCommandPalette();
|
||||||
this.renderCommandPalette();
|
this.renderCommandPalette();
|
||||||
|
|
||||||
// BEFORE the steal, not after: `search.focus()` below is what loses the
|
|
||||||
// caller's focus, so the read has to happen while it is still there.
|
|
||||||
this._rememberOverlayFocus('_commandPalettePrevFocus');
|
|
||||||
search.focus();
|
search.focus();
|
||||||
search.select?.();
|
search.select?.();
|
||||||
},
|
},
|
||||||
|
|
||||||
closeCommandPalette() {
|
closeCommandPalette() {
|
||||||
const modal = document.getElementById('commandPaletteModal');
|
const modal = document.getElementById('commandPaletteModal');
|
||||||
// ⚠️ Bail out when it was not open. The global Escape handler calls this on
|
if (modal) modal.classList.remove('active');
|
||||||
// EVERY Escape (app.js), in the CAPTURE phase, so an unconditional restore
|
|
||||||
// runs before the focused element's own Escape handler and steals focus into
|
|
||||||
// the terminal: keys typed after Escape in split Pane B land in Pane A, keys
|
|
||||||
// typed in any text field land in the terminal, and the inline tab rename's
|
|
||||||
// Escape fires the input's blur (which commits) before its own handler
|
|
||||||
// (which cancels), turning a cancel into a rename.
|
|
||||||
if (!modal?.classList?.contains('active')) return;
|
|
||||||
modal.classList.remove('active');
|
|
||||||
this._restoreOverlayFocus('_commandPalettePrevFocus', modal);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_wireCommandPalette() {
|
_wireCommandPalette() {
|
||||||
@@ -705,7 +618,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
search.value = '';
|
search.value = '';
|
||||||
this._rememberOverlayFocus('_sessionManagerPrevFocus');
|
|
||||||
search.focus();
|
search.focus();
|
||||||
}
|
}
|
||||||
await this._loadSessionManagerList('');
|
await this._loadSessionManagerList('');
|
||||||
@@ -713,10 +625,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
closeSessionManager() {
|
closeSessionManager() {
|
||||||
const modal = document.getElementById('sessionManagerModal');
|
const modal = document.getElementById('sessionManagerModal');
|
||||||
// Same guard as closeCommandPalette — see the note there.
|
if (modal) modal.classList.remove('active');
|
||||||
if (!modal?.classList?.contains('active')) return;
|
|
||||||
modal.classList.remove('active');
|
|
||||||
this._restoreOverlayFocus('_sessionManagerPrevFocus', modal);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Replace the Session Manager list body with a single status line. */
|
/** Replace the Session Manager list body with a single status line. */
|
||||||
@@ -4103,10 +4012,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.filePreviewDetachUrl = '';
|
this.filePreviewDetachUrl = '';
|
||||||
const detachBtn = this.$('filePreviewDetachBtn');
|
const detachBtn = this.$('filePreviewDetachBtn');
|
||||||
if (detachBtn) detachBtn.hidden = true;
|
if (detachBtn) detachBtn.hidden = true;
|
||||||
// Same for the text-view toggles: they act on the text this load has not
|
|
||||||
// fetched yet, and an image or PDF has nothing for them to toggle.
|
|
||||||
this.filePreviewText = null;
|
|
||||||
this._updateFilePreviewToolbar('none');
|
|
||||||
|
|
||||||
// Show overlay with loading state
|
// Show overlay with loading state
|
||||||
overlay.classList.add('visible');
|
overlay.classList.add('visible');
|
||||||
@@ -4190,19 +4095,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
// Markdown keeps every line the Range read returned: the byte bound is
|
const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
|
||||||
// what protects the tab, and a rendered document cut at 500 lines
|
const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
|
||||||
// reads as the whole document.
|
bodyEl.innerHTML = `<pre><code>${escapeHtml(shown)}</code></pre>`;
|
||||||
const lineCap = MARKDOWN_EXTS.has(ext) ? Infinity : TEXT_PREVIEW_MAX_LINES;
|
|
||||||
const clippedByLines = lines.length > lineCap;
|
|
||||||
const shown = clippedByLines ? lines.slice(0, lineCap).join('\n') : text;
|
|
||||||
this.filePreviewContent = shown;
|
this.filePreviewContent = shown;
|
||||||
// attachmentId: a card's filePath is the bare file name, so the
|
|
||||||
// rebase pass must know there is no directory to resolve against.
|
|
||||||
this.filePreviewText = { ext, sessionId, filePath, attachmentId };
|
|
||||||
this._renderFilePreviewText();
|
|
||||||
if (clippedByLines || clippedByBytes) {
|
if (clippedByLines || clippedByBytes) {
|
||||||
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
|
const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
|
||||||
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -4248,13 +4146,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 500 lines is what keeps a huge log from locking the tab in one <pre>;
|
|
||||||
// markdown is rendered as a document and takes the route's ceiling instead.
|
|
||||||
const lineCap = MARKDOWN_EXTS.has(ext) ? MARKDOWN_PREVIEW_MAX_LINES : TEXT_PREVIEW_MAX_LINES;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||||
`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=${lineCap}`
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error('Failed to load file');
|
if (!res.ok) throw new Error('Failed to load file');
|
||||||
|
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
@@ -4279,11 +4172,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||||
footerEl.textContent = data.extension || 'binary';
|
footerEl.textContent = data.extension || 'binary';
|
||||||
} else {
|
} else {
|
||||||
// Text content: rendered markdown or plain text, per the viewer's toggles.
|
// Text content
|
||||||
this.filePreviewContent = data.content;
|
this.filePreviewContent = data.content;
|
||||||
this.filePreviewText = { ext, sessionId, filePath };
|
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
||||||
this._renderFilePreviewText();
|
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
||||||
const truncNote = data.truncated ? ` (showing ${lineCap}/${data.totalLines} lines)` : '';
|
|
||||||
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
||||||
// Edit affordance only when the server says an edit=1 re-fetch would
|
// Edit affordance only when the server says an edit=1 re-fetch would
|
||||||
// succeed (workspace text file inside the allowlist and size cap).
|
// succeed (workspace text file inside the allowlist and size cap).
|
||||||
@@ -4311,8 +4203,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// audible and keeps streaming from the server. Closing has to stop it.
|
// audible and keeps streaming from the server. Closing has to stop it.
|
||||||
this._stopFilePreviewMedia();
|
this._stopFilePreviewMedia();
|
||||||
this.filePreviewContent = '';
|
this.filePreviewContent = '';
|
||||||
this.filePreviewText = null;
|
|
||||||
this._updateFilePreviewToolbar('none');
|
|
||||||
this.filePreviewDetachUrl = '';
|
this.filePreviewDetachUrl = '';
|
||||||
const detachBtn = this.$('filePreviewDetachBtn');
|
const detachBtn = this.$('filePreviewDetachBtn');
|
||||||
if (detachBtn) detachBtn.hidden = true;
|
if (detachBtn) detachBtn.hidden = true;
|
||||||
@@ -4361,204 +4251,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = '';
|
bodyEl.innerHTML = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
// File Viewer text view: rendered markdown, line numbers, wrap
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
_filePreviewPref(name) {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(FILE_PREVIEW_PREF_KEYS[name]);
|
|
||||||
if (stored === '1') return true;
|
|
||||||
if (stored === '0') return false;
|
|
||||||
} catch {
|
|
||||||
/* private mode: fall through to the default */
|
|
||||||
}
|
|
||||||
return FILE_PREVIEW_PREF_DEFAULTS[name];
|
|
||||||
},
|
|
||||||
|
|
||||||
_setFilePreviewPref(name, on) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(FILE_PREVIEW_PREF_KEYS[name], on ? '1' : '0');
|
|
||||||
} catch {
|
|
||||||
/* private mode: the toggle still applies for this page load */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paint the loaded text (filePreviewContent) into the preview body: a
|
|
||||||
* rendered document for .md/.markdown while the MD toggle is on, otherwise
|
|
||||||
* plain text with one span per line so the Lines toggle can number them.
|
|
||||||
* The MD toggle re-runs this without a refetch.
|
|
||||||
*
|
|
||||||
* Markdown goes through the same pipeline as the Response Viewer
|
|
||||||
* (`_renderMarkdown`: marked + the DOMPurify allowlist), never a second
|
|
||||||
* parser, and is built inside a <template>: a detached div with innerHTML
|
|
||||||
* already set starts fetching every <img src>, so the document's relative
|
|
||||||
* image paths would hit the server as /docs/img.png 404s before
|
|
||||||
* `_rebaseFilePreviewMarkdownRefs` rewrote them. `breaks: false` because a
|
|
||||||
* file is not a chat message: a paragraph hard-wrapped in the source is one
|
|
||||||
* paragraph, as on GitHub.
|
|
||||||
*/
|
|
||||||
_renderFilePreviewText() {
|
|
||||||
const info = this.filePreviewText;
|
|
||||||
const bodyEl = this.$('filePreviewBody');
|
|
||||||
if (!info || !bodyEl) return;
|
|
||||||
const isMarkdown = MARKDOWN_EXTS.has(info.ext);
|
|
||||||
const rendered = isMarkdown && this._filePreviewPref('mdRendered');
|
|
||||||
if (rendered) {
|
|
||||||
// data-i18n-skip: the translator's MutationObserver would otherwise
|
|
||||||
// rewrite the document's own headings and paragraphs.
|
|
||||||
const tmpl = document.createElement('template');
|
|
||||||
tmpl.innerHTML = `<div class="rv-text file-preview-md" data-i18n-skip>${this._renderMarkdown(this.filePreviewContent, { breaks: false })}</div>`;
|
|
||||||
const doc = tmpl.content.firstElementChild;
|
|
||||||
this._rebaseFilePreviewMarkdownRefs(doc, info);
|
|
||||||
this._linkifyFilePaths(doc);
|
|
||||||
// The linkifier's absolute paths name no session; give them the
|
|
||||||
// preview's, like the rebased links, or they open in the active tab's.
|
|
||||||
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
|
|
||||||
bodyEl.replaceChildren(tmpl.content);
|
|
||||||
// The Response Viewer's click delegate (path links, code-block copy
|
|
||||||
// buttons, loopback links): container-bound and idempotent, so binding it
|
|
||||||
// on the body once serves every preview.
|
|
||||||
this._bindResponseViewerInteractions(bodyEl);
|
|
||||||
} else {
|
|
||||||
const pre = document.createElement('pre');
|
|
||||||
pre.className = 'file-preview-text';
|
|
||||||
pre.classList.toggle('wrap', this._filePreviewPref('wrap'));
|
|
||||||
pre.classList.toggle('show-lines', this._filePreviewPref('lineNumbers'));
|
|
||||||
const code = document.createElement('code');
|
|
||||||
// One span per line joined by real newlines: empty lines survive, select
|
|
||||||
// and copy return the exact text, and the gutter counter hangs off the
|
|
||||||
// spans' ::before so the numbers are never part of the text.
|
|
||||||
code.innerHTML = this.filePreviewContent
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => `<span class="fp-line">${escapeHtml(line)}</span>`)
|
|
||||||
.join('\n');
|
|
||||||
pre.appendChild(code);
|
|
||||||
bodyEl.replaceChildren(pre);
|
|
||||||
}
|
|
||||||
this._updateFilePreviewToolbar(rendered ? 'markdown' : 'text');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Point a rendered document's workspace references at the file it came from.
|
|
||||||
*
|
|
||||||
* Images are rebased onto the workspace-confined file-raw route under the
|
|
||||||
* document's directory, root-relative ones (`/docs/x.png`) under the
|
|
||||||
* workspace root as on GitHub (the server refuses escapes, so `..` is safe
|
|
||||||
* to forward). Whatever fails to load degrades to its alt text with one
|
|
||||||
* error handler: a remote image the page CSP blocks, a 404 for a document
|
|
||||||
* outside the workspace, an SVG that file-raw serves as a download. Links
|
|
||||||
* take the `a.rv-path` shape the Response Viewer delegate already opens in
|
|
||||||
* this overlay, minus the target/rel `_renderMarkdown` gave them, which
|
|
||||||
* would otherwise open <origin>/docs/x.md in a new tab, and carry the
|
|
||||||
* preview's own session so a document opened from another session's
|
|
||||||
* attachment card resolves against that workspace, not the active tab's.
|
|
||||||
*
|
|
||||||
* A preview opened by attachment id under a bare file name (attachment
|
|
||||||
* cards and the history drawer: the registry keeps no relative path) has no
|
|
||||||
* directory to resolve against, and the workspace root is the wrong one for
|
|
||||||
* docs/report.md and for a file outside the workspace alike. Its workspace
|
|
||||||
* refs degrade instead: images to their alt text, links to their text,
|
|
||||||
* rather than a missing image or a silently different file.
|
|
||||||
*/
|
|
||||||
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
|
|
||||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
|
||||||
const unresolvable = !!attachmentId && !filePath.startsWith('/');
|
|
||||||
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
|
|
||||||
const isWorkspaceRef = (ref) =>
|
|
||||||
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
|
|
||||||
// GitHub-style `img.png#gh-dark-mode-only`, `doc.md#section` and
|
|
||||||
// `img.png?raw=true`: neither fragment nor query is part of the path.
|
|
||||||
// marked percent-encodes destinations (`my image.png` arrives as
|
|
||||||
// `my%20image.png`), so decode before the route encodes again, or file-raw
|
|
||||||
// looks for a file literally named `my%20image.png`; a malformed escape
|
|
||||||
// keeps the ref as written. `.` and `..` segments are collapsed so the
|
|
||||||
// title reads `README.md`, not `docs/../README.md`; a `..` that climbs
|
|
||||||
// past the start is kept and left for the server to refuse.
|
|
||||||
const resolveRef = (ref) => {
|
|
||||||
let rel = ref.split('#')[0].split('?')[0];
|
|
||||||
try {
|
|
||||||
rel = decodeURIComponent(rel);
|
|
||||||
} catch {
|
|
||||||
/* malformed escape: keep the ref as written */
|
|
||||||
}
|
|
||||||
const parts = [];
|
|
||||||
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
|
|
||||||
if (seg === '.' || (seg === '' && parts.length)) continue;
|
|
||||||
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
|
|
||||||
else parts.push(seg);
|
|
||||||
}
|
|
||||||
return parts.join('/');
|
|
||||||
};
|
|
||||||
for (const img of root.querySelectorAll('img[src]')) {
|
|
||||||
const src = img.getAttribute('src') || '';
|
|
||||||
if (unresolvable && isWorkspaceRef(src)) {
|
|
||||||
img.replaceWith(img.getAttribute('alt') || src);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isWorkspaceRef(src)) {
|
|
||||||
const path = resolveRef(src);
|
|
||||||
img.setAttribute('src', CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(path)}`));
|
|
||||||
}
|
|
||||||
img.addEventListener('error', () => img.replaceWith(img.getAttribute('alt') || src), { once: true });
|
|
||||||
}
|
|
||||||
for (const a of root.querySelectorAll('a[href]')) {
|
|
||||||
const href = a.getAttribute('href') || '';
|
|
||||||
if (!isWorkspaceRef(href)) continue;
|
|
||||||
if (unresolvable) {
|
|
||||||
a.replaceWith(...a.childNodes);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
a.className = 'rv-path';
|
|
||||||
a.dataset.path = resolveRef(href);
|
|
||||||
a.dataset.sessionId = sessionId;
|
|
||||||
a.setAttribute('href', '#');
|
|
||||||
a.removeAttribute('target');
|
|
||||||
a.removeAttribute('rel');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the toggles that apply to the current view: MD for a markdown file in
|
|
||||||
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
|
|
||||||
* image, media, PDF, edit mode) hides all three.
|
|
||||||
*/
|
|
||||||
_updateFilePreviewToolbar(view) {
|
|
||||||
const set = (id, shown, pressed) => {
|
|
||||||
const btn = this.$(id);
|
|
||||||
if (!btn) return;
|
|
||||||
btn.hidden = !shown;
|
|
||||||
if (shown) btn.setAttribute('aria-pressed', String(pressed));
|
|
||||||
};
|
|
||||||
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
|
|
||||||
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
|
|
||||||
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
|
|
||||||
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleFilePreviewMd() {
|
|
||||||
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
|
|
||||||
this._renderFilePreviewText();
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleFilePreviewLines() {
|
|
||||||
this._toggleFilePreviewTextClass('lineNumbers', 'show-lines');
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleFilePreviewWrap() {
|
|
||||||
this._toggleFilePreviewTextClass('wrap', 'wrap');
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Lines and Wrap are pure class flips on the <pre>; no re-render needed. */
|
|
||||||
_toggleFilePreviewTextClass(pref, className) {
|
|
||||||
const on = !this._filePreviewPref(pref);
|
|
||||||
this._setFilePreviewPref(pref, on);
|
|
||||||
const pre = this.$('filePreviewBody')?.querySelector(':scope > pre.file-preview-text');
|
|
||||||
if (pre) pre.classList.toggle(className, on);
|
|
||||||
this._updateFilePreviewToolbar('text');
|
|
||||||
},
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -4626,8 +4318,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
||||||
bodyEl.innerHTML = '';
|
bodyEl.innerHTML = '';
|
||||||
bodyEl.appendChild(textarea);
|
bodyEl.appendChild(textarea);
|
||||||
// The MD/Lines/Wrap toggles act on the text view this textarea replaced.
|
|
||||||
this._updateFilePreviewToolbar('none');
|
|
||||||
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
||||||
// before the user has scrolled to the line they want to change.
|
// before the user has scrolled to the line they want to change.
|
||||||
|
|
||||||
|
|||||||
@@ -84,10 +84,7 @@
|
|||||||
/**
|
/**
|
||||||
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
|
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
|
||||||
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
|
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
|
||||||
* blocks may carry `language-*` classes from marked. `name` is absent on purpose: marked never
|
* blocks may carry `language-*` classes from marked.
|
||||||
* emits it, and `<img name="app">` would make `document.app` that image, which every inline
|
|
||||||
* `onclick="app.…()"` handler resolves before the global (DOM clobbering), so one rendered
|
|
||||||
* README could break every button until a reload.
|
|
||||||
*/
|
*/
|
||||||
var ALLOWED_ATTR = [
|
var ALLOWED_ATTR = [
|
||||||
'href',
|
'href',
|
||||||
@@ -96,6 +93,7 @@
|
|||||||
'title',
|
'title',
|
||||||
'class',
|
'class',
|
||||||
'id',
|
'id',
|
||||||
|
'name',
|
||||||
'colspan',
|
'colspan',
|
||||||
'rowspan',
|
'rowspan',
|
||||||
'align',
|
'align',
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ const RUN_MODE_LAUNCH = {
|
|||||||
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||||
* ninth CLI landed in one and not the other.
|
* ninth CLI landed in one and not the other.
|
||||||
*/
|
*/
|
||||||
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
|
|
||||||
const CASE_PICKER_REFRESH_MS = 5000;
|
|
||||||
|
|
||||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||||
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
|
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
|
||||||
|
|
||||||
@@ -249,74 +246,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const input = document.getElementById('quickStartCaseSearch');
|
const input = document.getElementById('quickStartCaseSearch');
|
||||||
const list = document.getElementById('quickStartCaseList');
|
const list = document.getElementById('quickStartCaseList');
|
||||||
if (!input || !list) return;
|
if (!input || !list) return;
|
||||||
const wasOpen = this._casePickerOpen === true;
|
|
||||||
this._casePickerOpen = true;
|
this._casePickerOpen = true;
|
||||||
this._casePickerFilter = filter;
|
this._casePickerFilter = filter;
|
||||||
this._casePickerActiveIndex = 0;
|
this._casePickerActiveIndex = 0;
|
||||||
input.setAttribute('aria-expanded', 'true');
|
input.setAttribute('aria-expanded', 'true');
|
||||||
this.renderCasePickerList();
|
this.renderCasePickerList();
|
||||||
// Every keystroke re-enters here, so only the closed -> open transition
|
|
||||||
// refreshes and arms the timer; typing must not fire a fetch per key.
|
|
||||||
if (!wasOpen) this._startCasePickerRefresh();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Re-read the case list while the picker is open, so folders deleted or created on disk show up without a page reload. */
|
|
||||||
_startCasePickerRefresh() {
|
|
||||||
void this.refreshCasePickerCases();
|
|
||||||
if (this._casePickerRefreshTimer) return;
|
|
||||||
this._casePickerRefreshTimer = setInterval(() => void this.refreshCasePickerCases(), CASE_PICKER_REFRESH_MS);
|
|
||||||
},
|
|
||||||
|
|
||||||
_stopCasePickerRefresh() {
|
|
||||||
if (this._casePickerRefreshTimer) clearInterval(this._casePickerRefreshTimer);
|
|
||||||
this._casePickerRefreshTimer = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lighter than loadQuickStartCases(): that one closes the picker and re-picks a
|
|
||||||
* selection, which would yank the list away from someone mid-browse. This only
|
|
||||||
* swaps the data and repaints, and does nothing when the list is unchanged.
|
|
||||||
*/
|
|
||||||
async refreshCasePickerCases() {
|
|
||||||
if (this._casePickerRefreshInFlight) return;
|
|
||||||
this._casePickerRefreshInFlight = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/cases');
|
|
||||||
if (!res.ok) return;
|
|
||||||
const cases = (await res.json()).data;
|
|
||||||
if (!Array.isArray(cases) || !this._casePickerOpen) return;
|
|
||||||
const signature = list => JSON.stringify((list || []).map(c => [c.name, c.path, c.location]));
|
|
||||||
if (signature(cases) === signature(this.cases)) return;
|
|
||||||
this.cases = cases;
|
|
||||||
|
|
||||||
const select = document.getElementById('quickStartCase');
|
|
||||||
if (select) {
|
|
||||||
const previous = select.value;
|
|
||||||
this.renderQuickStartCaseSelectOptions(select, this.getCasePickerOptions());
|
|
||||||
if (cases.some(c => c.name === previous)) {
|
|
||||||
select.value = previous;
|
|
||||||
} else if (cases.length > 0) {
|
|
||||||
// The selected case was removed on disk: fall back the way the initial
|
|
||||||
// load does, without saving it as the user's last-used case.
|
|
||||||
const fallback = cases.find(c => c.name === 'testcase') || cases[0];
|
|
||||||
select.value = fallback.name;
|
|
||||||
this.updateDirDisplayForCase(fallback.name);
|
|
||||||
this.updateMobileCaseLabel(fallback.name);
|
|
||||||
this.updateCasePickerInput(fallback.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.renderCasePickerList();
|
|
||||||
} catch {
|
|
||||||
// A failed poll leaves the list as it was; the next tick retries.
|
|
||||||
} finally {
|
|
||||||
this._casePickerRefreshInFlight = false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
closeCasePicker() {
|
closeCasePicker() {
|
||||||
const input = document.getElementById('quickStartCaseSearch');
|
const input = document.getElementById('quickStartCaseSearch');
|
||||||
const list = document.getElementById('quickStartCaseList');
|
const list = document.getElementById('quickStartCaseList');
|
||||||
this._stopCasePickerRefresh();
|
|
||||||
this._casePickerOpen = false;
|
this._casePickerOpen = false;
|
||||||
this._casePickerFilter = '';
|
this._casePickerFilter = '';
|
||||||
input?.setAttribute('aria-expanded', 'false');
|
input?.setAttribute('aria-expanded', 'false');
|
||||||
@@ -4347,11 +4286,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Case Management (reorder + delete)
|
// Case Management (reorder + delete)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
setCaseManageFilter(value) {
|
|
||||||
this._caseManageFilter = String(value || '');
|
|
||||||
this.renderCaseManageList();
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCaseManageList() {
|
renderCaseManageList() {
|
||||||
const container = document.getElementById('caseManageList');
|
const container = document.getElementById('caseManageList');
|
||||||
const cases = this.cases || [];
|
const cases = this.cases || [];
|
||||||
@@ -4360,18 +4294,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every term must appear in the name or path (same rule as the Run picker's
|
|
||||||
// filter). Reordering stays on the FULL list, so the arrows are disabled while
|
|
||||||
// a filter is active: a swap with a neighbour the user cannot see is a surprise.
|
|
||||||
const terms = (this._caseManageFilter || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
||||||
const filtering = terms.length > 0;
|
|
||||||
const visible = filtering
|
|
||||||
? cases.filter(c => {
|
|
||||||
const haystack = `${c.name} ${c.path || ''}`.toLowerCase();
|
|
||||||
return terms.every(term => haystack.includes(term));
|
|
||||||
})
|
|
||||||
: cases;
|
|
||||||
|
|
||||||
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
||||||
// A long orchestration leaves one scratch directory per worker behind, so they get
|
// A long orchestration leaves one scratch directory per worker behind, so they get
|
||||||
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
||||||
@@ -4383,14 +4305,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
if (filtering && visible.length === 0) {
|
cases.forEach((c, idx) => {
|
||||||
html += '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases match</div>';
|
const isFirst = idx === 0;
|
||||||
}
|
const isLast = idx === cases.length - 1;
|
||||||
visible.forEach(c => {
|
|
||||||
const idx = cases.indexOf(c);
|
|
||||||
const isFirst = filtering || idx === 0;
|
|
||||||
const isLast = filtering || idx === cases.length - 1;
|
|
||||||
const reorderTitle = filtering ? 'Clear the search to reorder' : null;
|
|
||||||
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
||||||
// case path on a Linux host rendered in full, unabbreviated.
|
// case path on a Linux host rendered in full, unabbreviated.
|
||||||
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
||||||
@@ -4414,9 +4331,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲</button>
|
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼</button>
|
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Delete case">✕</button>
|
title="Delete case">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10984,19 +10984,6 @@ kbd {
|
|||||||
.file-preview-actions {
|
.file-preview-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
/* The title yields on a phone, not the buttons. */
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-actions .btn-icon-sm[aria-pressed='true'] {
|
|
||||||
color: var(--accent, #4ea1ff);
|
|
||||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-actions .file-preview-pill {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-preview-body {
|
.file-preview-body {
|
||||||
@@ -11021,58 +11008,6 @@ kbd {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- File Viewer text view: Lines / Wrap toggles ----
|
|
||||||
Child-combinator scoped so none of this leaks into the code blocks of a
|
|
||||||
rendered markdown document, which are <pre>s too. */
|
|
||||||
|
|
||||||
.file-preview-body > pre.file-preview-text {
|
|
||||||
counter-reset: fp-line;
|
|
||||||
tab-size: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-body > pre.file-preview-text:not(.wrap) {
|
|
||||||
white-space: pre;
|
|
||||||
word-break: normal;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Numbers hug the left edge (4px, left-aligned) instead of sitting behind the
|
|
||||||
pre's own padding right-aligned in a 4ch column, where "1" landed 40px in. */
|
|
||||||
.file-preview-body > pre.file-preview-text.show-lines {
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-body > pre.file-preview-text.show-lines .fp-line::before {
|
|
||||||
counter-increment: fp-line;
|
|
||||||
content: counter(fp-line);
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 4ch;
|
|
||||||
margin-right: 1ch;
|
|
||||||
text-align: left;
|
|
||||||
color: var(--text-muted);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- File Viewer rendered markdown ----
|
|
||||||
Styling comes from the Response Viewer's .rv-text rules; .rv-text itself
|
|
||||||
carries no padding or base font (the chat card supplies those). */
|
|
||||||
|
|
||||||
.file-preview-body > .file-preview-md {
|
|
||||||
padding: 1rem 1.25rem 2rem;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.55;
|
|
||||||
max-width: 960px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Relative links in the document are rebased onto a.rv-path so the Response
|
|
||||||
Viewer delegate opens them here; keep them reading as prose, not as paths.
|
|
||||||
Three-class selector on purpose: `.rv-text a.rv-path` (the monospace path
|
|
||||||
style) is declared later in this file and ties on specificity otherwise. */
|
|
||||||
.file-preview-body .file-preview-md a.rv-path {
|
|
||||||
font: inherit;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-body img {
|
.file-preview-body img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@@ -16506,15 +16441,6 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
|||||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An exited agent (Ark0N/Codeman#446): neutral, like the rich rail's exited pill.
|
|
||||||
No green at all, since nothing is running behind this row. The dot and the row
|
|
||||||
take the `--exited` class too and fall back to their neutral base rules. */
|
|
||||||
.home-sessions-pill--exited {
|
|
||||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
|
||||||
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accent, deliberately none of the three above: a session that is watching something
|
/* Accent, deliberately none of the three above: a session that is watching something
|
||||||
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
|
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
|
||||||
anything, so it must not borrow the red or the yellow that mean it is. This badge
|
anything, so it must not borrow the red or the yellow that mean it is. This badge
|
||||||
|
|||||||
@@ -14,9 +14,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
(function (global) {
|
(function (global) {
|
||||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
|
||||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||||
@@ -71,18 +68,10 @@
|
|||||||
this.fitAddon = null;
|
this.fitAddon = null;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this._wsReady = false;
|
this._wsReady = false;
|
||||||
this._wsClosed = false;
|
|
||||||
this._destroyed = false;
|
this._destroyed = false;
|
||||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||||
this._bufferLoading = false;
|
this._bufferLoading = false;
|
||||||
this._bufferRefreshPending = false;
|
this._bufferRefreshPending = false;
|
||||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
|
||||||
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
|
|
||||||
// are held there with their arrival time instead of written under it.
|
|
||||||
this._historyPullAt = 0;
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
this._liveQueue = null;
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
@@ -106,8 +95,6 @@
|
|||||||
this.terminal.open(this.mountEl);
|
this.terminal.open(this.mountEl);
|
||||||
this.fitAddon.fit();
|
this.fitAddon.fit();
|
||||||
|
|
||||||
this._installWheelListener();
|
|
||||||
|
|
||||||
this.terminal.onData((data) => {
|
this.terminal.onData((data) => {
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||||
@@ -267,9 +254,9 @@
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (msg.t === 'o') {
|
if (msg.t === 'o') {
|
||||||
this._onLiveOutput(msg.d);
|
this.terminal.write(msg.d);
|
||||||
} else if (msg.t === 'c') {
|
} else if (msg.t === 'c') {
|
||||||
this._onLiveClear();
|
this.terminal.clear();
|
||||||
} else if (msg.t === 'r') {
|
} else if (msg.t === 'r') {
|
||||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||||
// data was dropped). The primary pane routes this to
|
// data was dropped). The primary pane routes this to
|
||||||
@@ -293,30 +280,16 @@
|
|||||||
// normal while it quietly ate everything typed into it. v1 scope is
|
// normal while it quietly ate everything typed into it. v1 scope is
|
||||||
// "say so", not reconnect — collapsing the split would lose the
|
// "say so", not reconnect — collapsing the split would lose the
|
||||||
// user's place in Pane B's scrollback for a transient blip.
|
// user's place in Pane B's scrollback for a transient blip.
|
||||||
this.ws.onclose = () => this._onSocketClosed();
|
this.ws.onclose = () => {
|
||||||
|
this._wsReady = false;
|
||||||
|
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
this.ws.onerror = () => {
|
||||||
// onclose fires after onerror — cleanup happens there.
|
// onclose fires after onerror — cleanup happens there.
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The socket's close, split out of connect() so the tests can drive it.
|
|
||||||
// While a history pull is running the marker waits for the pull's finally
|
|
||||||
// block: written now, it would sit above the output the pull is still
|
|
||||||
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
|
|
||||||
// land in the middle of a chunked replay.
|
|
||||||
_onSocketClosed() {
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = true;
|
|
||||||
if (!this._liveQueue) this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extracted so both _onSocketClosed() and a history pull that ends on a
|
|
||||||
// closed socket can write it (see _pullHistory()'s finally block).
|
|
||||||
_writeDisconnectedMarker() {
|
|
||||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches and writes the session's current scrollback. Used both by
|
// Fetches and writes the session's current scrollback. Used both by
|
||||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||||
@@ -351,168 +324,14 @@
|
|||||||
} catch {
|
} catch {
|
||||||
/* Best-effort — live output still arrives once the socket connects. */
|
/* Best-effort — live output still arrives once the socket connects. */
|
||||||
} finally {
|
} finally {
|
||||||
this._endBufferLoad();
|
this._bufferLoading = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
|
||||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
|
||||||
_endBufferLoad() {
|
|
||||||
this._bufferLoading = false;
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) {
|
if (this._bufferRefreshPending && !this._destroyed) {
|
||||||
this._bufferRefreshPending = false;
|
this._bufferRefreshPending = false;
|
||||||
this._refreshBuffer();
|
this._refreshBuffer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live terminal output. Written straight through, except while a history
|
|
||||||
// pull is replaying: a capture is current only up to the instant tmux took
|
|
||||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
|
||||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
|
||||||
// _finishBufferLoad `since` rule), never written underneath it.
|
|
||||||
_onLiveOutput(data) {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
|
||||||
else this.terminal?.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
|
||||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
|
||||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
|
||||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
|
||||||
_onLiveClear() {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
|
||||||
else this.terminal?.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
|
||||||
// event it consumes, so a bubbling listener here would never see the wheel
|
|
||||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
|
||||||
// xterm keeps doing the scrolling.
|
|
||||||
_installWheelListener() {
|
|
||||||
this._onWheel = (ev) => {
|
|
||||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
|
||||||
};
|
|
||||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
|
||||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
|
||||||
// so this pane's xterm ends up with about one screen of scrollback while
|
|
||||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
|
||||||
// history was unreachable. The primary pane has the same pull
|
|
||||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
|
||||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
|
||||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
|
||||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
|
||||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
|
||||||
// tmux the browser xterm never enters the alternate buffer.
|
|
||||||
_maybeLoadMoreHistory() {
|
|
||||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
|
||||||
// _sendResize(): a detached session's own window already owns its PTY
|
|
||||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const active = this.terminal.buffer.active;
|
|
||||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
|
||||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
|
||||||
// rather than latch; a pull that could only have downgraded the pane
|
|
||||||
// waits far longer.
|
|
||||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this._historyPullAt < cooldown) return;
|
|
||||||
this._historyPullAt = now;
|
|
||||||
void this._pullHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
|
||||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
|
||||||
// main thread) and replays it under the reader's current place. Holds the
|
|
||||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
|
||||||
async _pullHistory() {
|
|
||||||
// A close before the pull already wrote its marker; one during it did not.
|
|
||||||
const closedBefore = this._wsClosed;
|
|
||||||
this._bufferLoading = true;
|
|
||||||
this._liveQueue = [];
|
|
||||||
let replayed = false;
|
|
||||||
let capturedAt = 0;
|
|
||||||
try {
|
|
||||||
// A deadline, because live output is held for as long as this runs: a
|
|
||||||
// request that hangs would otherwise freeze the whole pane. Aborting
|
|
||||||
// lands in the catch below, which releases the flag and the queue. It
|
|
||||||
// covers the body read too, not just the headers.
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
|
||||||
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
// The cutoff below is the response's arrival, the same `since` rule the
|
|
||||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
|
||||||
// for the instant tmux took the capture, which lies somewhere in the
|
|
||||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
|
||||||
// by one round trip and not closable without a server-side capture time.
|
|
||||||
capturedAt = performance.now();
|
|
||||||
const payload = (await res.json())?.data;
|
|
||||||
const buffer = payload?.terminalBuffer;
|
|
||||||
const term = this.terminal;
|
|
||||||
if (!buffer || !term || this._destroyed) return;
|
|
||||||
const rowsBefore = term.buffer.active.length;
|
|
||||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
|
||||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
|
||||||
// lines, so a window of short lines can carry more rows than this pane
|
|
||||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
|
||||||
const scrollbackCap = term.options?.scrollback || 0;
|
|
||||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
|
||||||
// Nothing to gain (this also covers a downgrade, which would delete
|
|
||||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
|
||||||
// untruncated window IS all of tmux's history and the next burst can add
|
|
||||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
|
||||||
// what the pane shows, and every ask costs the server a capture-pane of
|
|
||||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
|
||||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
|
||||||
// off too, since no window can ever fit in it.
|
|
||||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
|
||||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
term.write('\x1bc');
|
|
||||||
replayed = true;
|
|
||||||
await writeChunked(term, buffer, () => this._destroyed);
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// xterm parses asynchronously: an empty write's callback fires only
|
|
||||||
// after everything before it, so the row count below is the settled one.
|
|
||||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
|
||||||
// rows down; land there and the recovered history sits above it.
|
|
||||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
|
||||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
|
||||||
else this.terminal.scrollToTop();
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
|
||||||
} finally {
|
|
||||||
const queued = this._liveQueue ?? [];
|
|
||||||
this._liveQueue = null;
|
|
||||||
// After a replay, only frames that arrived after the capture are news;
|
|
||||||
// earlier ones are already in it. With no replay, every held frame is.
|
|
||||||
const cutoff = replayed ? capturedAt : 0;
|
|
||||||
for (const entry of queued) {
|
|
||||||
if (entry.at < cutoff) continue;
|
|
||||||
if (entry.clear) this.terminal?.clear();
|
|
||||||
else this.terminal?.write(entry.data);
|
|
||||||
}
|
|
||||||
// A replay's own `\x1bc` wipes a marker written before the pull,
|
|
||||||
// painting a fresh, current-looking history while onData keeps
|
|
||||||
// silently dropping every keystroke on the dead socket, so re-stamp it
|
|
||||||
// after a replay. A close DURING the pull wrote no marker at all
|
|
||||||
// (_onSocketClosed() defers it while the queue is live), so write it
|
|
||||||
// whether or not this pull replayed. Checked after the queue flush so
|
|
||||||
// it is the last thing on screen, matching what the close would have
|
|
||||||
// left had the pull never run.
|
|
||||||
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||||
// frames in a row used to start two concurrent replays, each clearing
|
// frames in a row used to start two concurrent replays, each clearing
|
||||||
// the terminal under the other's chunked write. A refresh that arrives
|
// the terminal under the other's chunked write. A refresh that arrives
|
||||||
@@ -562,10 +381,6 @@
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
if (this._onWheel) {
|
|
||||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.onopen = null;
|
this.ws.onopen = null;
|
||||||
this.ws.onmessage = null;
|
this.ws.onmessage = null;
|
||||||
|
|||||||
@@ -650,9 +650,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
||||||
// wheel reports (claude 2.1.187+ while it tracks the mouse, which only its
|
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
|
||||||
// fullscreen renderer does; see _shouldForwardWheelToApp), local scrollback
|
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||||
// otherwise. Claude Code 2.1.187+ scrolls its own
|
|
||||||
// transcript on SGR wheel reports — scrolled-away tool blocks re-render
|
// transcript on SGR wheel reports — scrolled-away tool blocks re-render
|
||||||
// live and stay clickable — and its select menus no longer capture wheel
|
// live and stay clickable — and its select menus no longer capture wheel
|
||||||
// as option navigation (verified against 2.1.202: /model menu highlight
|
// as option navigation (verified against 2.1.202: /model menu highlight
|
||||||
@@ -724,7 +723,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// phone/tablet swipe scrolls the local buffer of stale repaint frames and
|
// phone/tablet swipe scrolls the local buffer of stale repaint frames and
|
||||||
// drags the CLI's pinned input box off the screen (issue #205's mobile
|
// drags the CLI's pinned input box off the screen (issue #205's mobile
|
||||||
// half). Same gate, so Shift has no touch analog but the local-scrollback
|
// half). Same gate, so Shift has no touch analog but the local-scrollback
|
||||||
// opt-out setting and the version/tracking gate apply to touch exactly as they
|
// opt-out setting and the CLI-version gate apply to touch exactly as they
|
||||||
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
|
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
|
||||||
// when that gate is false and there is no local scrollback to scroll
|
// when that gate is false and there is no local scrollback to scroll
|
||||||
// (_maybePageCliTranscript), which is what keeps a swipe from being a
|
// (_maybePageCliTranscript), which is what keeps a swipe from being a
|
||||||
@@ -3305,9 +3304,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/**
|
/**
|
||||||
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
||||||
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
||||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions pull a
|
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
|
||||||
* bounded window because their captures can be large; their banner button is
|
* automatic pulls because their captures can be large; their banner button is
|
||||||
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
|
* the explicit path. Must be called AFTER scrollLines(), since the check is on
|
||||||
* the resulting position, and it is deliberately not folded into
|
* the resulting position, and it is deliberately not folded into
|
||||||
* _noteTerminalUserScroll for exactly that reason.
|
* _noteTerminalUserScroll for exactly that reason.
|
||||||
*/
|
*/
|
||||||
@@ -3355,17 +3354,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* below the last line, and _estimateReplayRows can only approximate wrapping.
|
* below the last line, and _estimateReplayRows can only approximate wrapping.
|
||||||
* Only a capture that is worse by more than a full screen counts as a
|
* Only a capture that is worse by more than a full screen counts as a
|
||||||
* downgrade, which leaves every genuine recovery case untouched.
|
* downgrade, which leaves every genuine recovery case untouched.
|
||||||
*
|
|
||||||
* A caller that already estimated the capture's rows passes them as
|
|
||||||
* `estimatedRows`, so a megabyte capture is not scanned twice.
|
|
||||||
*/
|
*/
|
||||||
_replayWouldShrinkBuffer(capture, estimatedRows) {
|
_replayWouldShrinkBuffer(capture) {
|
||||||
const term = this.terminal;
|
const term = this.terminal;
|
||||||
const rowsNow = term?.buffer?.active?.length || 0;
|
const rowsNow = term?.buffer?.active?.length || 0;
|
||||||
if (!rowsNow) return false;
|
if (!rowsNow) return false;
|
||||||
const screen = term?.rows || 24;
|
const screen = term?.rows || 24;
|
||||||
const rows = estimatedRows ?? this._estimateReplayRows(capture, term?.cols);
|
return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow;
|
||||||
return rows + screen < rowsNow;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5087,10 +5082,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
||||||
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
||||||
// scroll its transcript on SGR wheel reports, which today is claude 2.1.187+
|
// scroll its transcript on SGR wheel reports — which today is claude 2.1.187+
|
||||||
// with mouse tracking on (fullscreen) and nothing else (older Claude Code
|
// and nothing else (older Claude Code captures wheel as select-menu option
|
||||||
// captures wheel as select-menu option navigation, an unknown version is
|
// navigation; an unknown version is treated as older). Gemini and codex are
|
||||||
// treated as older, and inline Claude ignores it). Gemini and codex are
|
|
||||||
// strip modes too but keep the local wheel — taps/clicks are still forwarded
|
// strip modes too but keep the local wheel — taps/clicks are still forwarded
|
||||||
// for them (harmless no-ops at worst).
|
// for them (harmless no-ops at worst).
|
||||||
//
|
//
|
||||||
@@ -5157,15 +5151,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const sessionMode = session?.mode || 'claude';
|
const sessionMode = session?.mode || 'claude';
|
||||||
if (sessionMode !== 'claude') return false;
|
if (sessionMode !== 'claude') return false;
|
||||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||||
// Only while Claude is actually listening for the mouse. In its default
|
|
||||||
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the
|
|
||||||
// transcript lives in real scrollback, like codex, and SGR wheel reports are
|
|
||||||
// ignored, so forwarding made every swipe and wheel tick dead. Fullscreen
|
|
||||||
// (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json)
|
|
||||||
// turns on alt-screen + mode 1003/1006, which the server records as
|
|
||||||
// cliMouseTracking. A stale-false flag after a server restart falls through
|
|
||||||
// to _maybePageCliTranscript, so it never goes dead.
|
|
||||||
if (session?.cliMouseTracking !== true) return false;
|
|
||||||
// Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so
|
// Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so
|
||||||
// that leaving the bottom handed the wheel back to local scrollback and both
|
// that leaving the bottom handed the wheel back to local scrollback and both
|
||||||
// histories stayed reachable without a mode switch. In practice that inverted
|
// histories stayed reachable without a mode switch. In practice that inverted
|
||||||
@@ -5239,13 +5224,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
*
|
*
|
||||||
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
||||||
* on a Claude session that has no local history to fall back on: the CLI
|
* on a Claude session that has no local history to fall back on: the CLI
|
||||||
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
|
* version probe failed or is genuinely older than 2.1.187, or the user turned
|
||||||
* tracking flag is unset (the inline renderer, or fullscreen right after a
|
* on "Wheel scrolls local history" (which pins the wheel to a buffer that,
|
||||||
* server restart), or the user turned on "Wheel scrolls local history" (which
|
* for a repaint-mode CLI, is empty — the setting's footgun). Before this, all
|
||||||
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
|
* of those produced a completely dead gesture; the #205 reporter proved the
|
||||||
* setting's footgun). Before this, all of those produced a completely dead
|
* keyboard route works by paging back through intact text with Fn+Up.
|
||||||
* gesture; the #205 reporter proved the keyboard route works by paging back
|
|
||||||
* through intact text with Fn+Up.
|
|
||||||
*
|
*
|
||||||
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
|
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
|
||||||
* real local scrollback is never touched. Shift is excluded on purpose: it is
|
* real local scrollback is never touched. Shift is excluded on purpose: it is
|
||||||
@@ -5289,19 +5272,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const session = this.sessions?.get(sessionId);
|
const session = this.sessions?.get(sessionId);
|
||||||
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
|
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
|
||||||
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
|
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
|
||||||
// xterm's own mode above stays 'none' for a strip mode (the server removes
|
|
||||||
// the DECSETs), so the CLI's real tracking state is reported separately.
|
|
||||||
const cliTracking = session?.cliMouseTracking === true;
|
|
||||||
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
|
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
|
||||||
const signature =
|
const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`;
|
||||||
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${cliTracking}|${baseY > 0}`;
|
|
||||||
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
|
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
|
||||||
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
|
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
|
||||||
this._scrollRoutingLogged.set(sessionId, signature);
|
this._scrollRoutingLogged.set(sessionId, signature);
|
||||||
console.log(
|
console.log(
|
||||||
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
|
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
|
||||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, cliMouseTracking=${cliTracking}, ` +
|
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})`
|
||||||
`localScrollbackRows=${baseY})`
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -274,10 +274,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// one `/`, and refuse whatever still opens a second one. The proxied form
|
// one `/`, and refuse whatever still opens a second one. The proxied form
|
||||||
// is refused server-side as well (resolveUpstreamUrl).
|
// is refused server-side as well (resolveUpstreamUrl).
|
||||||
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
|
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
|
||||||
void this.openWebview(id, {
|
void this.openWebview(id, { path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/' });
|
||||||
path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/',
|
|
||||||
auto: true,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -359,23 +356,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {{path?: string, auto?: boolean}} [options] `path` (pathname+search+hash) opens a
|
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
|
||||||
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
* deep link inside the dashboard: appended to the proxy prefix, or resolved
|
||||||
* against the real URL in direct mode. A mounted frame is navigated there
|
* against the real URL in direct mode. A mounted frame is navigated there
|
||||||
* rather than left on whatever page it was showing. `auto: true` marks an
|
* rather than left on whatever page it was showing.
|
||||||
* open the APP made (a frame recovering itself, the fallback after the
|
|
||||||
* active web tab closes), as on selectSession().
|
|
||||||
*/
|
*/
|
||||||
async openWebview(id, options = {}) {
|
async openWebview(id, options = {}) {
|
||||||
const webview = this.webviews.get(id);
|
const webview = this.webviews.get(id);
|
||||||
if (!webview) return;
|
if (!webview) return;
|
||||||
|
|
||||||
// Opening a web tab yourself is choosing something else, so a
|
|
||||||
// `#session=<id>` link still waiting for its session must not take the
|
|
||||||
// screen from this tab later. Retired before the await below, which a
|
|
||||||
// session:created could otherwise land inside.
|
|
||||||
if (options.auto !== true) this._retireUrlSession?.();
|
|
||||||
|
|
||||||
if (!this.webviewOrder.includes(id)) {
|
if (!this.webviewOrder.includes(id)) {
|
||||||
this.webviewOrder.push(id);
|
this.webviewOrder.push(id);
|
||||||
this._persistWebviewOrder();
|
this._persistWebviewOrder();
|
||||||
@@ -524,7 +513,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.activeWebviewId = null;
|
this.activeWebviewId = null;
|
||||||
const next = this.webviewOrder[0];
|
const next = this.webviewOrder[0];
|
||||||
if (next) {
|
if (next) {
|
||||||
this.openWebview(next, { auto: true });
|
this.openWebview(next);
|
||||||
} else {
|
} else {
|
||||||
this._hideWebviewLayer();
|
this._hideWebviewLayer();
|
||||||
// Fall back to whatever session was last shown, or the welcome screen.
|
// Fall back to whatever session was last shown, or the welcome screen.
|
||||||
|
|||||||
@@ -437,26 +437,6 @@ export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?:
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether an input body is a plain prompt: printable text followed by exactly one
|
|
||||||
* carriage return, and nothing else.
|
|
||||||
*
|
|
||||||
* That is the shape a script, a bot or a curl call sends to submit a prompt, and the
|
|
||||||
* one that must NOT be written into the pane in one piece. Measured on Claude Code
|
|
||||||
* 2.1.283 (2026-09-28): a direct write of `<text>\r` arrives as a single burst, and a
|
|
||||||
* burst of about a hundred characters or more is taken as a paste, so its trailing
|
|
||||||
* `\r` lands as a NEWLINE in the composer and the prompt sits there unsent. A later
|
|
||||||
* bare `\r` written the same way does not recover it; a tmux `send-keys Enter` does.
|
|
||||||
* Short bursts (tens of characters) submit, which is why the failure looked random.
|
|
||||||
*
|
|
||||||
* Anything with another control character (escape sequences, a bracketed-paste frame,
|
|
||||||
* a line feed, a tab, C1 controls) is raw terminal input and keeps the direct write.
|
|
||||||
*/
|
|
||||||
export function isPlainPromptInput(input: string): boolean {
|
|
||||||
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
||||||
return /^[^\x00-\x1f\x7f-\x9f]+\r$/.test(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist session state and broadcast a SessionUpdated event.
|
* Persist session state and broadcast a SessionUpdated event.
|
||||||
* Replaces the repeated two-line pattern across route handlers.
|
* Replaces the repeated two-line pattern across route handlers.
|
||||||
|
|||||||
@@ -210,22 +210,13 @@ const installsInFlight = new Set<string>();
|
|||||||
/**
|
/**
|
||||||
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
||||||
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
||||||
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
|
* data dir, the tmux socket), none of which an installer needs.
|
||||||
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
|
|
||||||
* so an `npm install -g` lands on the persistent home mount instead of the image.
|
|
||||||
*/
|
*/
|
||||||
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||||
const env: NodeJS.ProcessEnv = {};
|
const env: NodeJS.ProcessEnv = {};
|
||||||
for (const [key, value] of Object.entries(source)) {
|
for (const [key, value] of Object.entries(source)) {
|
||||||
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
||||||
}
|
}
|
||||||
// ⚠️ In the Docker Compose deployment the image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is IMAGE
|
|
||||||
// content: `Update-Codeman.sh` recreates the container and every CLI installed there (dsh, pi, ...)
|
|
||||||
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
|
|
||||||
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
|
|
||||||
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
|
|
||||||
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
|
|
||||||
}
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1724,7 +1724,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
||||||
// so the file viewer can open the same files.
|
// so the file viewer can open the same files.
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico']);
|
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||||
// Shared with the attachment registry so a video plays the same whether it
|
// Shared with the attachment registry so a video plays the same whether it
|
||||||
// sits in the workspace or is reached by id from outside it.
|
// sits in the workspace or is reached by id from outside it.
|
||||||
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
|
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
|
||||||
@@ -2045,7 +2045,6 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
gif: 'image/gif',
|
gif: 'image/gif',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
avif: 'image/avif',
|
|
||||||
ico: 'image/x-icon',
|
ico: 'image/x-icon',
|
||||||
bmp: 'image/bmp',
|
bmp: 'image/bmp',
|
||||||
mp4: 'video/mp4',
|
mp4: 'video/mp4',
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ import {
|
|||||||
findSessionOrFail,
|
findSessionOrFail,
|
||||||
getAuthUser,
|
getAuthUser,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isPlainPromptInput,
|
|
||||||
isWorkingDirAllowed,
|
isWorkingDirAllowed,
|
||||||
ownerFor,
|
ownerFor,
|
||||||
parseBody,
|
parseBody,
|
||||||
@@ -1836,18 +1835,10 @@ export function registerSessionRoutes(
|
|||||||
// the wrong recovery — wait longer, when the truth is "restart the worker".
|
// the wrong recovery — wait longer, when the truth is "restart the worker".
|
||||||
let delivered = false;
|
let delivered = false;
|
||||||
|
|
||||||
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
|
|
||||||
// ask for it: written straight into the pane it arrives as one burst, and Claude
|
|
||||||
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
|
|
||||||
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
|
|
||||||
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
|
|
||||||
// raw write for a caller that really wants it.
|
|
||||||
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
|
|
||||||
|
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
// Redelivery of an already-applied input: skip the write, but still honor the
|
// Redelivery of an already-applied input: skip the write, but still honor the
|
||||||
// wait, since the caller's question ("tell me when this settles") is unanswered.
|
// wait, since the caller's question ("tell me when this settles") is unanswered.
|
||||||
} else if ((useMux || autoMux) && waitPromise) {
|
} else if (useMux && waitPromise) {
|
||||||
// The response is already staying open for the wait, so the tmux write can be
|
// The response is already staying open for the wait, so the tmux write can be
|
||||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||||
@@ -1858,16 +1849,6 @@ export function registerSessionRoutes(
|
|||||||
delivered = session.write(inputStr, { fromUser: true });
|
delivered = session.write(inputStr, { fromUser: true });
|
||||||
if (!delivered) undoOnFailure();
|
if (!delivered) undoOnFailure();
|
||||||
}
|
}
|
||||||
} else if (autoMux) {
|
|
||||||
// Awaited, unlike the explicit useMux branch below. This shape also reaches here
|
|
||||||
// from the browser's POST fallback, which sends its frames one at a time and
|
|
||||||
// waits for each 2xx; answering only once Enter has gone out is what keeps the
|
|
||||||
// next keystroke from overtaking it.
|
|
||||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
|
||||||
if (!ok) {
|
|
||||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
|
||||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
|
||||||
}
|
|
||||||
} else if (useMux) {
|
} else if (useMux) {
|
||||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||||
|
|||||||
@@ -165,11 +165,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
|||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
|
||||||
action: 'created',
|
|
||||||
id: created.id,
|
|
||||||
owner: ownerLayoutKey(created.owner),
|
|
||||||
});
|
|
||||||
return { success: true, data: created };
|
return { success: true, data: created };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,11 +195,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
|||||||
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
||||||
// against the OLD url keeps proxying to it after the user repointed the tab.
|
// against the OLD url keeps proxying to it after the user repointed the tab.
|
||||||
webviewCapabilities.revokeWebview(id);
|
webviewCapabilities.revokeWebview(id);
|
||||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
|
||||||
action: 'updated',
|
|
||||||
id,
|
|
||||||
owner: ownerLayoutKey(updated.owner),
|
|
||||||
});
|
|
||||||
return { success: true, data: updated };
|
return { success: true, data: updated };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,11 +231,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
|||||||
|
|
||||||
webviewCapabilities.revokeWebview(id);
|
webviewCapabilities.revokeWebview(id);
|
||||||
socketCounts.delete(id);
|
socketCounts.delete(id);
|
||||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
|
||||||
action: 'deleted',
|
|
||||||
id,
|
|
||||||
owner: ownerLayoutKey(result.owner),
|
|
||||||
});
|
|
||||||
return { success: true, data: { id } };
|
return { success: true, data: { id } };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-104
@@ -33,8 +33,7 @@ import fastifyCookie from '@fastify/cookie';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import fastifyWebsocket from '@fastify/websocket';
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
import fastifyMultipart from '@fastify/multipart';
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
|
import { startPasteImageGc } from './paste-image-gc.js';
|
||||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
||||||
@@ -96,7 +95,6 @@ import { PushSubscriptionStore } from '../push-store.js';
|
|||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { SseStreamManager } from './sse-stream-manager.js';
|
import { SseStreamManager } from './sse-stream-manager.js';
|
||||||
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
|
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
|
||||||
import { deriveWebviewSseHint } from './webview-sse.js';
|
|
||||||
import {
|
import {
|
||||||
type SessionListenerRefs,
|
type SessionListenerRefs,
|
||||||
createSessionListeners,
|
createSessionListeners,
|
||||||
@@ -1323,32 +1321,16 @@ export class WebServer extends EventEmitter {
|
|||||||
// Clean up all resources associated with a session
|
// Clean up all resources associated with a session
|
||||||
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
||||||
private cleaningUp: Set<string> = new Set();
|
private cleaningUp: Set<string> = new Set();
|
||||||
/**
|
|
||||||
* The subset of {@link cleaningUp} whose tmux session is being KILLED rather
|
|
||||||
* than detached. The paste-image guard needs the difference: a detaching
|
|
||||||
* session keeps running in tmux and still uses its working directory.
|
|
||||||
*/
|
|
||||||
private killingSessions: Set<string> = new Set();
|
|
||||||
|
|
||||||
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
|
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
|
||||||
// Guard against concurrent cleanup of the same session
|
// Guard against concurrent cleanup of the same session
|
||||||
if (this.cleaningUp.has(sessionId)) return;
|
if (this.cleaningUp.has(sessionId)) return;
|
||||||
this.cleaningUp.add(sessionId);
|
this.cleaningUp.add(sessionId);
|
||||||
if (killMux) this.killingSessions.add(sessionId);
|
|
||||||
// Refuse a start or attach from here on (Ark0N/Codeman#446): a start that
|
|
||||||
// raced this cleanup would launch a CLI in a tmux session whose record is
|
|
||||||
// about to be deleted, leaving an orphan the next boot rediscovers.
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
session?.markClosing(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this._doCleanupSession(sessionId, killMux, reason);
|
await this._doCleanupSession(sessionId, killMux, reason);
|
||||||
} finally {
|
} finally {
|
||||||
this.cleaningUp.delete(sessionId);
|
this.cleaningUp.delete(sessionId);
|
||||||
this.killingSessions.delete(sessionId);
|
|
||||||
// A cleanup that failed leaves the session on the board, so it must be
|
|
||||||
// startable again.
|
|
||||||
if (this.sessions.get(sessionId) === session) session?.markClosing(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,24 +1482,8 @@ export class WebServer extends EventEmitter {
|
|||||||
attachmentRegistry.clearSession(sessionId);
|
attachmentRegistry.clearSession(sessionId);
|
||||||
// Stop watching for images in this session's directory
|
// Stop watching for images in this session's directory
|
||||||
imageWatcher.unwatchSession(sessionId);
|
imageWatcher.unwatchSession(sessionId);
|
||||||
// Clean up pasted images directory for this session. The dir belongs to the
|
// Clean up pasted images directory for this session
|
||||||
// working directory rather than the session, so it stays while another live
|
if (killMux && session.workingDir) {
|
||||||
// session in the same case still uses it (Ark0N/Codeman#446).
|
|
||||||
if (
|
|
||||||
killMux &&
|
|
||||||
session.workingDir &&
|
|
||||||
!pasteImageDirInUseByOtherSession({
|
|
||||||
live: this.sessions.values(),
|
|
||||||
persisted: Object.entries(this.store.getSessions()).map(([id, record]) => ({
|
|
||||||
id,
|
|
||||||
workingDir: record.workingDir,
|
|
||||||
status: record.status,
|
|
||||||
})),
|
|
||||||
closingId: sessionId,
|
|
||||||
workingDir: session.workingDir,
|
|
||||||
killing: this.killingSessions,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
const pasteImageDir = join(session.workingDir, '.claude-images');
|
const pasteImageDir = join(session.workingDir, '.claude-images');
|
||||||
try {
|
try {
|
||||||
rmSync(pasteImageDir, { recursive: true, force: true });
|
rmSync(pasteImageDir, { recursive: true, force: true });
|
||||||
@@ -2462,12 +2428,6 @@ export class WebServer extends EventEmitter {
|
|||||||
if (event.startsWith('tab:')) {
|
if (event.startsWith('tab:')) {
|
||||||
return deriveTabLayoutSseHint(data);
|
return deriveTabLayoutSseHint(data);
|
||||||
}
|
}
|
||||||
// Saved-webview invalidations carry the trusted resource owner. Route them to
|
|
||||||
// that owner (plus admins), so an admin editing a user's web tab notifies the
|
|
||||||
// user, and no other user learns the ids of someone else's web tabs.
|
|
||||||
if (event.startsWith('webview:')) {
|
|
||||||
return deriveWebviewSseHint(data);
|
|
||||||
}
|
|
||||||
// Session-scoped families: resolve the owner from the payload's session id.
|
// Session-scoped families: resolve the owner from the payload's session id.
|
||||||
const SESSION_PREFIXES = [
|
const SESSION_PREFIXES = [
|
||||||
'session:',
|
'session:',
|
||||||
@@ -2542,9 +2502,6 @@ export class WebServer extends EventEmitter {
|
|||||||
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
|
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
|
||||||
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
|
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
|
||||||
* what makes the browser re-attach and launch a fresh CLI.
|
* what makes the browser re-attach and launch a fresh CLI.
|
||||||
*
|
|
||||||
* Once the records are current, {@link closeCleanlyExitedSessions} closes the
|
|
||||||
* sessions whose agent the user ended.
|
|
||||||
*/
|
*/
|
||||||
private applyPaneExits(): void {
|
private applyPaneExits(): void {
|
||||||
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
|
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
|
||||||
@@ -2558,63 +2515,6 @@ export class WebServer extends EventEmitter {
|
|||||||
this.persistSessionState(session);
|
this.persistSessionState(session);
|
||||||
this.broadcastSessionStateDebounced(session.id);
|
this.broadcastSessionStateDebounced(session.id);
|
||||||
}
|
}
|
||||||
this.closeCleanlyExitedSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close every session whose agent exited cleanly, through the same
|
|
||||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
|
||||||
* is demoted to `status: 'stopped'` there rather than removed, and either way
|
|
||||||
* the reboot restore stops offering it back. The conversation stays
|
|
||||||
* resumable, since the Resume list reads the lifecycle log and the transcript
|
|
||||||
* files, and the pane owns neither.
|
|
||||||
*
|
|
||||||
* `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`) holds the rule:
|
|
||||||
* an explicit status of 0, confirmed by more than one pane read, with no
|
|
||||||
* start or attach in flight and not within seconds of one (a startup error). A crashed agent keeps its row with the exit
|
|
||||||
* code on it. `session.paneExit` is already scoped to local mux-backed
|
|
||||||
* sessions by `setPaneExit()`, so a remote, docker or direct-PTY session is
|
|
||||||
* never closed here.
|
|
||||||
*
|
|
||||||
* The close runs in the background. `cleanupSession()` ignores a second call
|
|
||||||
* for a session it is already closing, and the `closing` check below keeps
|
|
||||||
* the next tick from queueing one.
|
|
||||||
*
|
|
||||||
* Each exit is attempted ONCE, keyed by session id and the exit's `at`
|
|
||||||
* stamp. A close that fails leaves the session on the board with its exit
|
|
||||||
* badge, which is where a crashed agent's row would be too, rather than
|
|
||||||
* retrying and logging every two seconds. A new exit in the same pane has a
|
|
||||||
* new `at` and gets its own attempt.
|
|
||||||
*/
|
|
||||||
/** Exits the clean-exit sweep has already tried to close, as `<sessionId>:<exit.at>`. */
|
|
||||||
private cleanExitCloseAttempts: Set<string> = new Set();
|
|
||||||
|
|
||||||
private closeCleanlyExitedSessions(): void {
|
|
||||||
const readCount = this.mux.getPaneExitReadCount?.bind(this.mux);
|
|
||||||
if (!readCount) return;
|
|
||||||
// Forget attempts for sessions that are gone, so the set stays bounded.
|
|
||||||
for (const key of this.cleanExitCloseAttempts) {
|
|
||||||
if (!this.sessions.has(key.slice(0, key.lastIndexOf(':')))) this.cleanExitCloseAttempts.delete(key);
|
|
||||||
}
|
|
||||||
for (const session of [...this.sessions.values()]) {
|
|
||||||
const muxName = session.muxName;
|
|
||||||
if (!muxName) continue;
|
|
||||||
const close = shouldCloseCleanlyExitedSession({
|
|
||||||
paneExit: session.paneExit,
|
|
||||||
confirmingReads: readCount(muxName),
|
|
||||||
paneLifecycleInFlight: session.paneLifecycleInFlight,
|
|
||||||
closing: this.cleaningUp.has(session.id),
|
|
||||||
paneStartedAt: session.paneStartedAt,
|
|
||||||
});
|
|
||||||
if (!close) continue;
|
|
||||||
const attempt = `${session.id}:${session.paneExit?.at ?? 0}`;
|
|
||||||
if (this.cleanExitCloseAttempts.has(attempt)) continue;
|
|
||||||
this.cleanExitCloseAttempts.add(attempt);
|
|
||||||
console.log(`[Server] Closing session ${session.id} (${session.name}): ${CLEAN_EXIT_CLOSE_REASON}`);
|
|
||||||
void this.cleanupSession(session.id, true, CLEAN_EXIT_CLOSE_REASON).catch((err) => {
|
|
||||||
console.error(`[Server] Failed to close cleanly exited session ${session.id}:`, err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Web Push ==========
|
// ========== Web Push ==========
|
||||||
@@ -4063,7 +3963,6 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.activePlanOrchestrators.clear();
|
this.activePlanOrchestrators.clear();
|
||||||
this.cleaningUp.clear();
|
this.cleaningUp.clear();
|
||||||
this.killingSessions.clear();
|
|
||||||
|
|
||||||
// Dispose push store (flush pending saves)
|
// Dispose push store (flush pending saves)
|
||||||
this.pushStore.dispose();
|
this.pushStore.dispose();
|
||||||
|
|||||||
@@ -481,10 +481,8 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
|
|||||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||||
|
|
||||||
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
||||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id, owner }`. The client
|
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
|
||||||
* re-fetches the list rather than patching from the payload. `owner` is the web
|
* re-fetches the list rather than patching from the payload. */
|
||||||
* tab's owner (`'@single'` when multi-user mode is off); in multi-user mode the
|
|
||||||
* event is delivered only to that owner and admins (`deriveWebviewSseHint`). */
|
|
||||||
export const WebviewChanged = 'webview:changed' as const;
|
export const WebviewChanged = 'webview:changed' as const;
|
||||||
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
|
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
|
||||||
export const TabLayoutChanged = 'tab:layoutChanged' as const;
|
export const TabLayoutChanged = 'tab:layoutChanged' as const;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
/** @fileoverview Trusted owner routing metadata for saved-webview invalidations. */
|
|
||||||
import type { SseRoutingHint } from './sse-stream-manager.js';
|
|
||||||
|
|
||||||
export function deriveWebviewSseHint(data: unknown): SseRoutingHint {
|
|
||||||
return { username: (data as { owner?: string }).owner, sessionScoped: true };
|
|
||||||
}
|
|
||||||
@@ -22,8 +22,6 @@ import {
|
|||||||
agentImageNpmPackages as mjsPackages,
|
agentImageNpmPackages as mjsPackages,
|
||||||
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
|
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
|
||||||
gitHostCliBuildArgPairs as mjsGitHostPairs,
|
gitHostCliBuildArgPairs as mjsGitHostPairs,
|
||||||
GIT_IDENTITY_BUILD_ARGS as mjsGitIdentityArgs,
|
|
||||||
gitIdentityBuildArgPairs as mjsGitIdentityPairs,
|
|
||||||
} from '../scripts/lib/cli-catalog.mjs';
|
} from '../scripts/lib/cli-catalog.mjs';
|
||||||
import {
|
import {
|
||||||
agentImageBuildArgPairs as tsPairs,
|
agentImageBuildArgPairs as tsPairs,
|
||||||
@@ -31,8 +29,6 @@ import {
|
|||||||
agentImageNpmPackages as tsPackages,
|
agentImageNpmPackages as tsPackages,
|
||||||
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
|
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
|
||||||
gitHostCliBuildArgPairs as tsGitHostPairs,
|
gitHostCliBuildArgPairs as tsGitHostPairs,
|
||||||
GIT_IDENTITY_BUILD_ARGS as tsGitIdentityArgs,
|
|
||||||
gitIdentityBuildArgPairs as tsGitIdentityPairs,
|
|
||||||
} from '../src/docker-hosts.js';
|
} from '../src/docker-hosts.js';
|
||||||
|
|
||||||
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
|
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
|
||||||
@@ -149,52 +145,3 @@ describe('optional gh / az in the agent image: both producers pass the same swit
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Git identity in the agent image: both producers pass the same settings', () => {
|
|
||||||
it('maps the Git environment variables to matching Dockerfile ARGs', () => {
|
|
||||||
expect(tsGitIdentityArgs).toEqual(mjsGitIdentityArgs);
|
|
||||||
expect(tsGitIdentityArgs).toEqual([
|
|
||||||
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
|
||||||
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes a complete identity and omits an absent identity', () => {
|
|
||||||
const identity = {
|
|
||||||
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace',
|
|
||||||
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com',
|
|
||||||
};
|
|
||||||
const expected: Array<[string, string]> = [
|
|
||||||
['GIT_USER_NAME', 'Ada Lovelace'],
|
|
||||||
['GIT_USER_EMAIL', 'ada@example.com'],
|
|
||||||
];
|
|
||||||
expect(tsGitIdentityPairs(identity)).toEqual(expected);
|
|
||||||
expect(mjsGitIdentityPairs(identity)).toEqual(expected);
|
|
||||||
expect(tsGitIdentityPairs({})).toEqual([]);
|
|
||||||
expect(mjsGitIdentityPairs({})).toEqual([]);
|
|
||||||
// The combined argv, not just the helper: the manual build path could drop the identity otherwise.
|
|
||||||
expect(tsPairs(identity)).toEqual(mjsPairs(CATALOG, identity));
|
|
||||||
expect(tsPairs(identity)).toEqual(expect.arrayContaining(expected));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a partial identity in both build paths', () => {
|
|
||||||
for (const identity of [
|
|
||||||
{ CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace' },
|
|
||||||
{ CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com' },
|
|
||||||
]) {
|
|
||||||
const named = /CODEMAN_AGENT_IMAGE_GIT_USER_NAME and CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL must both be set/;
|
|
||||||
expect(() => tsGitIdentityPairs(identity)).toThrow(named);
|
|
||||||
expect(() => mjsGitIdentityPairs(identity)).toThrow(named);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('both Dockerfiles configure system Git identity from the build arguments', () => {
|
|
||||||
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
|
|
||||||
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
|
|
||||||
expect(dockerfile, file).toMatch(/^ARG GIT_USER_NAME=$/m);
|
|
||||||
expect(dockerfile, file).toMatch(/^ARG GIT_USER_EMAIL=$/m);
|
|
||||||
expect(dockerfile, file).toContain('git config --system user.name "${GIT_USER_NAME}"');
|
|
||||||
expect(dockerfile, file).toContain('git config --system user.email "${GIT_USER_EMAIL}"');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview scripts/check-browser-test-excludes.mjs: the detection side (which test
|
|
||||||
* files need a real browser) and the leak computation. The exclusion side is vitest's own
|
|
||||||
* `vitest list`, which `npm run check:browser-excludes` exercises for real in CI.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import {
|
|
||||||
findBrowserTests,
|
|
||||||
findLeaks,
|
|
||||||
findTestFiles,
|
|
||||||
importsBrowserDriver,
|
|
||||||
listingMatchesTree,
|
|
||||||
parseVitestFileList,
|
|
||||||
} from '../scripts/check-browser-test-excludes.mjs';
|
|
||||||
import { BROWSER_TEST_GLOBS } from '../config/test-suites';
|
|
||||||
|
|
||||||
const repoRoot = resolve(import.meta.dirname, '..');
|
|
||||||
|
|
||||||
// Fixture sources are assembled from the module name at runtime, so THIS file never contains
|
|
||||||
// a literal driver import and is not itself flagged by the checker it tests.
|
|
||||||
const fromImport = (mod: string) => `import { chromium, type Browser } from '${mod}';\n`;
|
|
||||||
|
|
||||||
describe('importsBrowserDriver', () => {
|
|
||||||
it.each([
|
|
||||||
fromImport('playwright'),
|
|
||||||
fromImport('playwright-core').replace(/'/g, '"'),
|
|
||||||
fromImport('@playwright/test'),
|
|
||||||
fromImport('puppeteer'),
|
|
||||||
`import type { Page } from '${'playwright'}';`,
|
|
||||||
`const { chromium } = require('${'playwright'}');`,
|
|
||||||
`const pw = await import('${'playwright'}');`,
|
|
||||||
])('flags %s', (src) => {
|
|
||||||
expect(importsBrowserDriver(src)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"import { describe } from 'vitest';",
|
|
||||||
"// needs ms-playwright's cache dir\nconst dir = '.cache/ms-playwright';",
|
|
||||||
fromImport('./playwright-helpers'),
|
|
||||||
fromImport('playwright-extra-thing'),
|
|
||||||
])('ignores %s', (src) => {
|
|
||||||
expect(importsBrowserDriver(src)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findBrowserTests (fixture tree)', () => {
|
|
||||||
let root: string;
|
|
||||||
beforeAll(() => {
|
|
||||||
root = mkdtempSync(join(tmpdir(), 'codeman-browser-excludes-'));
|
|
||||||
const put = (rel: string, src: string) => {
|
|
||||||
mkdirSync(join(root, rel, '..'), { recursive: true });
|
|
||||||
writeFileSync(join(root, rel), src);
|
|
||||||
};
|
|
||||||
put('test/unit.test.ts', "import { it } from 'vitest';\n");
|
|
||||||
put('test/legacy-name.test.ts', fromImport('playwright'));
|
|
||||||
put('test/new.browser.test.ts', fromImport('playwright'));
|
|
||||||
put('test/nested/deep.test.ts', fromImport('puppeteer'));
|
|
||||||
put('test/helpers/browser.ts', fromImport('playwright')); // not a test file
|
|
||||||
});
|
|
||||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
it('finds driver imports by content, recursively, as sorted repo-relative paths', () => {
|
|
||||||
expect(findBrowserTests(root)).toEqual([
|
|
||||||
'test/legacy-name.test.ts',
|
|
||||||
'test/nested/deep.test.ts',
|
|
||||||
'test/new.browser.test.ts',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists every test file, browser-driven or not, in the same form', () => {
|
|
||||||
expect(findTestFiles(root)).toEqual([
|
|
||||||
'test/legacy-name.test.ts',
|
|
||||||
'test/nested/deep.test.ts',
|
|
||||||
'test/new.browser.test.ts',
|
|
||||||
'test/unit.test.ts',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseVitestFileList + findLeaks', () => {
|
|
||||||
it('keeps only test paths and normalizes a leading ./', () => {
|
|
||||||
const out = '\n./test/a.test.ts\ntest/b.test.ts\nsome banner line\n test/c.test.ts \n';
|
|
||||||
expect([...parseVitestFileList(out)].sort()).toEqual(['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports exactly the browser tests the CI set still collects', () => {
|
|
||||||
const ci = new Set(['test/unit.test.ts', 'test/legacy-name.test.ts']);
|
|
||||||
expect(findLeaks(['test/legacy-name.test.ts', 'test/new.browser.test.ts'], ci)).toEqual([
|
|
||||||
'test/legacy-name.test.ts',
|
|
||||||
]);
|
|
||||||
expect(findLeaks(['test/new.browser.test.ts'], ci)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flags a non-empty listing whose paths never match the tree instead of passing vacuously', () => {
|
|
||||||
const tree = ['test/legacy-name.test.ts', 'test/unit.test.ts'];
|
|
||||||
// e.g. a vitest upgrade that starts printing absolute paths: nothing leaks, but only
|
|
||||||
// because nothing matches, so the checker must refuse rather than report success.
|
|
||||||
const drifted = parseVitestFileList('/repo/test/legacy-name.test.ts\n/repo/test/unit.test.ts\n');
|
|
||||||
expect(drifted.size).toBe(2);
|
|
||||||
expect(findLeaks(['test/legacy-name.test.ts'], drifted)).toEqual([]);
|
|
||||||
expect(listingMatchesTree(drifted, tree)).toBe(false);
|
|
||||||
|
|
||||||
const healthy = parseVitestFileList('test/legacy-name.test.ts\ntest/unit.test.ts\n');
|
|
||||||
expect(listingMatchesTree(healthy, tree)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('against this repository', () => {
|
|
||||||
it('detects every file already listed in BROWSER_TEST_GLOBS', () => {
|
|
||||||
// If detection stopped recognising a known browser test, the checker would go blind to
|
|
||||||
// exactly the class of file it exists for.
|
|
||||||
const detected = new Set(findBrowserTests(repoRoot));
|
|
||||||
const literals = BROWSER_TEST_GLOBS.filter((g) => !/[*?[{]/.test(g));
|
|
||||||
expect(literals.length).toBeGreaterThan(0);
|
|
||||||
for (const file of literals) expect(detected, file).toContain(file);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -165,24 +165,6 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
|||||||
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
|
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('holds the optional awaitingLine to the same guard', () => {
|
|
||||||
expectRejected((e) => {
|
|
||||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
|
||||||
promptGlyph: '>',
|
|
||||||
workingLine: 'working',
|
|
||||||
awaitingLine: '(a+)+b',
|
|
||||||
};
|
|
||||||
}, 'it is tested against a pane row every time a session settles');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts every shipped awaitingLine', () => {
|
|
||||||
for (const entry of STOCK_CLIS) {
|
|
||||||
const src = entry.capabilities.workDetect?.awaitingLine;
|
|
||||||
if (!src) continue;
|
|
||||||
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('no shell text can reach the command line', () => {
|
describe('no shell text can reach the command line', () => {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
|||||||
app.getSessionName = (session: any) =>
|
app.getSessionName = (session: any) =>
|
||||||
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
||||||
|
|
||||||
return { app, elements, listeners, makeClassList };
|
return { app, elements, listeners };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Command-K session palette', () => {
|
describe('Command-K session palette', () => {
|
||||||
@@ -480,150 +480,6 @@ describe('Session Manager unified list', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('overlay focus restoration (Escape must not strand the keyboard)', () => {
|
|
||||||
/**
|
|
||||||
* Both overlays focus their search box on open. Closing them used to leave
|
|
||||||
* focus on <body>, so after Escape every keystroke went nowhere until the
|
|
||||||
* user clicked the terminal — measured in a real browser against a shell
|
|
||||||
* session: activeElement BODY, zero onData for anything typed afterwards.
|
|
||||||
*/
|
|
||||||
function focusHarness() {
|
|
||||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
|
||||||
const priorElement = { focus: vi.fn(), isConnected: true, tagName: 'TEXTAREA' };
|
|
||||||
const body = { tagName: 'BODY' };
|
|
||||||
let active: any = priorElement;
|
|
||||||
// The harness builds its element map internally, so getElementById reads it
|
|
||||||
// through this binding, filled in once the harness returns.
|
|
||||||
let els: Record<string, any> = {};
|
|
||||||
const { app, elements, makeClassList } = loadPaletteHarness({
|
|
||||||
document: {
|
|
||||||
getElementById: (id: string) => els[id] ?? null,
|
|
||||||
get activeElement() {
|
|
||||||
return active;
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
els = elements;
|
|
||||||
app.terminal = { focus: terminalTextarea.focus };
|
|
||||||
app.activeSessionId = 'sess-beta';
|
|
||||||
// The overlay's own focus() is what moves focus in a real browser; the
|
|
||||||
// fake document needs the same transition or the test proves nothing.
|
|
||||||
elements.commandPaletteSearch.focus = vi.fn(() => {
|
|
||||||
active = elements.commandPaletteSearch;
|
|
||||||
});
|
|
||||||
// The restore keeps a focus that already left the overlay, so the modal has
|
|
||||||
// to know its own search box is inside it, as the real DOM does.
|
|
||||||
elements.commandPaletteModal.contains = (el: any) => el === elements.commandPaletteSearch;
|
|
||||||
// Same wiring for the Session Manager. A real classList: the close guard
|
|
||||||
// reads `contains('active')`, and a stub without it reports "not open" and
|
|
||||||
// skips the restore.
|
|
||||||
const installSessionManager = () => {
|
|
||||||
const search: any = { value: '', addEventListener: vi.fn() };
|
|
||||||
search.focus = vi.fn(() => {
|
|
||||||
active = search;
|
|
||||||
});
|
|
||||||
elements.sessionManagerSearch = search;
|
|
||||||
elements.sessionManagerModal = {
|
|
||||||
classList: makeClassList(),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
contains: (el: any) => el === search,
|
|
||||||
};
|
|
||||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
|
||||||
app._loadSessionManagerList = vi.fn();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
app,
|
|
||||||
elements,
|
|
||||||
priorElement,
|
|
||||||
terminalTextarea,
|
|
||||||
body,
|
|
||||||
makeClassList,
|
|
||||||
installSessionManager,
|
|
||||||
setActive: (v: any) => (active = v),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns focus to whatever had it when the command palette closes', () => {
|
|
||||||
const { app, priorElement } = focusHarness();
|
|
||||||
app.openCommandPalette();
|
|
||||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
||||||
app.closeCommandPalette();
|
|
||||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the terminal when the prior element is gone, but only with a live session', () => {
|
|
||||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
|
||||||
app.openCommandPalette();
|
|
||||||
priorElement.isConnected = false;
|
|
||||||
app.closeCommandPalette();
|
|
||||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
||||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never focuses the terminal from the welcome screen (a phone would pop the keyboard)', () => {
|
|
||||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
|
||||||
app.activeSessionId = null;
|
|
||||||
app.openCommandPalette();
|
|
||||||
priorElement.isConnected = false;
|
|
||||||
app.closeCommandPalette();
|
|
||||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not restore focus to <body>, which is the bug itself', () => {
|
|
||||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
|
||||||
setActive(body);
|
|
||||||
app.openCommandPalette();
|
|
||||||
app.closeCommandPalette();
|
|
||||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves focus alone when neither overlay was open — the path every Escape takes', () => {
|
|
||||||
// app.js's global Escape handler calls both close methods on EVERY Escape,
|
|
||||||
// in the capture phase. Nothing was saved, so an unguarded restore would fall
|
|
||||||
// through to the terminal and steal focus from split Pane B, from any text
|
|
||||||
// field, and turn the inline rename's Escape into a commit.
|
|
||||||
const { app, elements, terminalTextarea, priorElement, makeClassList } = focusHarness();
|
|
||||||
elements.sessionManagerModal = { classList: makeClassList(), addEventListener: vi.fn() };
|
|
||||||
app.closeCommandPalette();
|
|
||||||
app.closeSessionManager();
|
|
||||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
||||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not focus the terminal on touch while the keyboard is down', () => {
|
|
||||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
|
||||||
app._shouldFocusTerminalForTabSwitch = () => false;
|
|
||||||
setActive(body);
|
|
||||||
app.openCommandPalette();
|
|
||||||
app.closeCommandPalette();
|
|
||||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restores focus on the session manager too, not just the palette', async () => {
|
|
||||||
const { app, priorElement, installSessionManager } = focusHarness();
|
|
||||||
installSessionManager();
|
|
||||||
await app.openSessionManager();
|
|
||||||
app.closeSessionManager();
|
|
||||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the terminal focus the row menu gave it when the session manager closes', async () => {
|
|
||||||
// "Switch to session" and "Open folder" (terminal-ui.js) call selectSession(),
|
|
||||||
// which focuses the terminal on desktop, and only THEN closeSessionManager().
|
|
||||||
// Opened from its header button, the saved focus is that button, so an
|
|
||||||
// unconditional restore pulled focus off the session the user just picked.
|
|
||||||
const { app, priorElement, terminalTextarea, installSessionManager, setActive } = focusHarness();
|
|
||||||
installSessionManager();
|
|
||||||
await app.openSessionManager();
|
|
||||||
app.selectSession = vi.fn(() => setActive(terminalTextarea));
|
|
||||||
app.selectSession('sess-alpha');
|
|
||||||
app.closeSessionManager();
|
|
||||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
|
||||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('panel close helpers', () => {
|
describe('panel close helpers', () => {
|
||||||
it('closes panels when the mobile header helper is unavailable', () => {
|
it('closes panels when the mobile header helper is unavailable', () => {
|
||||||
const CodemanApp = function CodemanApp(this: any) {};
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
|||||||
@@ -16,13 +16,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import { CronService, clampCronExternalCliConfigs, type CronDeps } from '../src/cron/cron-service.js';
|
||||||
CronService,
|
|
||||||
clampCronExternalCliConfigs,
|
|
||||||
deliverCronPrompt,
|
|
||||||
type CronDeps,
|
|
||||||
} from '../src/cron/cron-service.js';
|
|
||||||
import { CRON_PASTE_ENTER_DELAY_MS } from '../src/config/server-timing.js';
|
|
||||||
import { CronJobSchema } from '../src/web/schemas.js';
|
import { CronJobSchema } from '../src/web/schemas.js';
|
||||||
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
|
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
|
||||||
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
||||||
@@ -711,77 +705,3 @@ describe('clampCronExternalCliConfigs', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Paste mode used to write `<text>\r` in one piece. Claude Code takes a raw burst of
|
|
||||||
* about a hundred characters as a paste and turns its `\r` into a newline, so the
|
|
||||||
* prompt sat unsent on the composer while the run said `prompt_sent`.
|
|
||||||
*/
|
|
||||||
describe('deliverCronPrompt', () => {
|
|
||||||
const PROMPT =
|
|
||||||
'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.';
|
|
||||||
|
|
||||||
function fakeTarget(ok = true) {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const target = {
|
|
||||||
write: vi.fn((d: string) => {
|
|
||||||
calls.push(`write:${JSON.stringify(d)}`);
|
|
||||||
return ok;
|
|
||||||
}),
|
|
||||||
writeViaMux: vi.fn(async (d: string) => {
|
|
||||||
calls.push(`mux:${JSON.stringify(d)}`);
|
|
||||||
return ok;
|
|
||||||
}),
|
|
||||||
verifySubmitted: vi.fn((t: string) => {
|
|
||||||
calls.push(`verify:${JSON.stringify(t)}`);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
return { target, calls };
|
|
||||||
}
|
|
||||||
const noWait = async (): Promise<void> => {};
|
|
||||||
|
|
||||||
it('paste mode writes the text and its Enter separately, then arms the composer check', async () => {
|
|
||||||
const { target, calls } = fakeTarget();
|
|
||||||
const waits: number[] = [];
|
|
||||||
|
|
||||||
const ok = await deliverCronPrompt(target, PROMPT, 'paste', async (ms) => {
|
|
||||||
waits.push(ms);
|
|
||||||
calls.push('wait');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(calls).toEqual([
|
|
||||||
`write:${JSON.stringify(PROMPT)}`,
|
|
||||||
'wait',
|
|
||||||
'write:"\\r"',
|
|
||||||
`verify:${JSON.stringify(PROMPT)}`,
|
|
||||||
]);
|
|
||||||
expect(waits).toEqual([CRON_PASTE_ENTER_DELAY_MS]);
|
|
||||||
expect(target.writeViaMux).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never puts the Enter in the same write as the text', async () => {
|
|
||||||
const { target } = fakeTarget();
|
|
||||||
|
|
||||||
await deliverCronPrompt(target, `${PROMPT}\r`, 'paste', noWait);
|
|
||||||
|
|
||||||
for (const [data] of target.write.mock.calls) {
|
|
||||||
expect(data === '\r' || !data.includes('\r')).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('typed mode is unchanged: one mux write that carries the Enter', async () => {
|
|
||||||
const { target, calls } = fakeTarget();
|
|
||||||
|
|
||||||
await deliverCronPrompt(target, PROMPT, 'typed', noWait);
|
|
||||||
|
|
||||||
expect(calls).toEqual([`mux:${JSON.stringify(`${PROMPT}\r`)}`]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a session it could not write to, instead of claiming the prompt went out', async () => {
|
|
||||||
const { target } = fakeTarget(false);
|
|
||||||
|
|
||||||
expect(await deliverCronPrompt(target, PROMPT, 'paste', noWait)).toBe(false);
|
|
||||||
expect(target.verifySubmitted).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -110,14 +110,15 @@ describe('docker-compose.yaml cap_add covers what entrypoint.sh and init:true ne
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('the runtime-owned CLI prefix never shadows root commands', () => {
|
describe('the runtime-owned CLI prefix never shadows root commands', () => {
|
||||||
it('server.Dockerfile appends the runtime-writable CLI dirs to PATH rather than prepending them', () => {
|
it('server.Dockerfile appends /opt/codeman-cli/bin to PATH rather than prepending it', () => {
|
||||||
const pathLines = dockerfile.split('\n').filter((l) => /^ENV PATH=/.test(l));
|
const pathLines = dockerfile.split('\n').filter((l) => /^ENV PATH=/.test(l));
|
||||||
expect(pathLines.length).toBeGreaterThan(0);
|
expect(pathLines.length).toBeGreaterThan(0);
|
||||||
for (const line of pathLines) {
|
for (const line of pathLines) {
|
||||||
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').toMatch(/^ENV PATH=\$PATH:/);
|
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').not.toMatch(
|
||||||
|
/^ENV PATH=\/opt\/codeman-cli/
|
||||||
|
);
|
||||||
}
|
}
|
||||||
expect(pathLines).toContain('ENV PATH=$PATH:/opt/codeman-cli/bin');
|
expect(pathLines).toContain('ENV PATH=$PATH:/opt/codeman-cli/bin');
|
||||||
expect(pathLines).toContain('ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('entrypoint.sh pins PATH to the system directories before its first command', () => {
|
it('entrypoint.sh pins PATH to the system directories before its first command', () => {
|
||||||
|
|||||||
@@ -1,473 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview File Viewer text view: rendered markdown plus Lines/Wrap toggles.
|
|
||||||
*
|
|
||||||
* Clicking a `.md` in the Files panel showed wrapped source with no way to see
|
|
||||||
* it rendered, although the Response Viewer's marked + DOMPurify pipeline
|
|
||||||
* (`_renderMarkdown`) was already on the page. The viewer now renders markdown
|
|
||||||
* through that same pipeline, with an MD toggle back to source, and the
|
|
||||||
* plain-text view gained Lines and Wrap toggles. Pinned here:
|
|
||||||
*
|
|
||||||
* 1. `.md` renders into `.rv-text.file-preview-md[data-i18n-skip]` while the
|
|
||||||
* pref is on and into a `<pre>` of per-line spans while it is off; the MD
|
|
||||||
* toggle re-renders WITHOUT a second fetch and persists per device.
|
|
||||||
* 2. Relative image refs are rebased onto the workspace-confined file-raw
|
|
||||||
* route under the document's directory and a failed load degrades to alt
|
|
||||||
* text; relative links become `a.rv-path` for the Response Viewer delegate
|
|
||||||
* and lose the `target` marked gave them, while fragment and http(s) links
|
|
||||||
* stay untouched.
|
|
||||||
* 3. Markdown fetches the route's line ceiling; other text keeps 500.
|
|
||||||
* 4. Lines/Wrap flip classes on the <pre> and persist, and the text the <pre>
|
|
||||||
* holds is byte-identical to the file; every toggle is hidden for an image
|
|
||||||
* and while editing.
|
|
||||||
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico and still has no `md`
|
|
||||||
* (in-workspace text keeps the tail viewer, see architecture-invariants).
|
|
||||||
* 6. A preview opened by attachment id under a bare file name has no
|
|
||||||
* directory to resolve against, so its relative images degrade to alt text
|
|
||||||
* and its relative links to plain text instead of landing on the workspace
|
|
||||||
* root's files; an absolute-path attachment keeps resolving.
|
|
||||||
* 7. A file renders without chat line breaks (`breaks: false`): a paragraph
|
|
||||||
* hard-wrapped in the source is one paragraph, while the Response Viewer
|
|
||||||
* keeps a <br> per newline.
|
|
||||||
*
|
|
||||||
* Loaded via `vm` with a jsdom document injected (the technique from
|
|
||||||
* response-viewer-file-links.test.ts): constants.js + panels-ui.js only, with
|
|
||||||
* the app.js markdown pipeline stubbed to a fixed fragment, except for rule 7,
|
|
||||||
* which runs the shipping app.js + vendored marked + DOMPurify end to end.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
import { JSDOM } from 'jsdom';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
||||||
const publicFile = (name: string) => readFileSync(resolve(PUBLIC, name), 'utf8');
|
|
||||||
const constantsJs = publicFile('constants.js');
|
|
||||||
const panelsJs = publicFile('panels-ui.js');
|
|
||||||
|
|
||||||
// A real origin: vitest's equality walker reaches the window through a node's
|
|
||||||
// ownerDocument, and jsdom's localStorage getter throws on an opaque one.
|
|
||||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
|
|
||||||
const { document } = dom.window;
|
|
||||||
|
|
||||||
/** What the stubbed `_renderMarkdown` hands back: every ref shape the rebase pass must classify. */
|
|
||||||
const MARKDOWN_HTML =
|
|
||||||
'<h1>Title</h1><p>x</p>' +
|
|
||||||
'<img src="img/a.png#gh-dark-mode-only" alt="Alt A">' +
|
|
||||||
'<img src="https://cdn.example.com/r.png" alt="remote">' +
|
|
||||||
'<a href="guide/x.md#sec" target="_blank" rel="noopener noreferrer">x</a>' +
|
|
||||||
'<a href="../CHANGELOG.md" target="_blank" rel="noopener noreferrer">up</a>' +
|
|
||||||
'<a href="#top">t</a>' +
|
|
||||||
'<a href="https://e.com" target="_blank" rel="noopener noreferrer">e</a>' +
|
|
||||||
// marked percent-encodes destinations; a query rides along on GitHub-style refs.
|
|
||||||
'<img src="my%20image.png" alt="space">' +
|
|
||||||
'<img src="raw.png?raw=true" alt="raw">' +
|
|
||||||
'<img src="bad%zz.png" alt="bad">' +
|
|
||||||
'<img src="/assets/root.png" alt="root">' +
|
|
||||||
'<img src="//cdn.example.com/p.png" alt="protorel">' +
|
|
||||||
'<a href="%E5%9B%BE%E7%89%87/%E6%88%AA%E5%9B%BE.md" target="_blank" rel="noopener noreferrer">cjk</a>' +
|
|
||||||
'<a href="/docs/root.md" target="_blank" rel="noopener noreferrer">rootlink</a>';
|
|
||||||
|
|
||||||
const MD_CONTENT = '# Title\n\nx\n';
|
|
||||||
const TXT_CONTENT = 'one\n\n three\tfour\n';
|
|
||||||
|
|
||||||
function jsonResponse(body: unknown) {
|
|
||||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer file-content like the route does: text as JSON, an image as metadata. */
|
|
||||||
function fetchStub(url: string) {
|
|
||||||
// An attachment's by-id raw route answers the bytes themselves.
|
|
||||||
if (url.includes('/attachments/')) return { ok: true, status: 200, text: async () => MD_CONTENT };
|
|
||||||
const path = decodeURIComponent(new URL(url, 'http://x').searchParams.get('path') || '');
|
|
||||||
const ext = path.split('.').pop() || '';
|
|
||||||
if (ext === 'png') {
|
|
||||||
return jsonResponse({
|
|
||||||
success: true,
|
|
||||||
data: { type: 'image', url: `/file-raw?path=${path}`, size: 5, extension: ext },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const content = ext === 'md' ? MD_CONTENT : TXT_CONTENT;
|
|
||||||
if (url.includes('edit=1')) {
|
|
||||||
return jsonResponse({
|
|
||||||
success: true,
|
|
||||||
data: { content, hash: 'h', eol: 'lf', totalLines: 3, size: content.length },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return jsonResponse({
|
|
||||||
success: true,
|
|
||||||
data: { path, content, totalLines: 3, size: content.length, truncated: false, extension: ext, editable: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The file-preview overlay's elements, as index.html ships them. */
|
|
||||||
function mountPreviewDom() {
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
|
|
||||||
<button id="filePreviewMdBtn" hidden></button>
|
|
||||||
<button id="filePreviewLinesBtn" hidden></button>
|
|
||||||
<button id="filePreviewWrapBtn" hidden></button>
|
|
||||||
<button id="filePreviewEditBtn" hidden></button>
|
|
||||||
<button id="filePreviewDetachBtn" hidden></button>
|
|
||||||
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadApp(prefs: Record<string, string> = {}) {
|
|
||||||
const store = new Map(Object.entries(prefs));
|
|
||||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
|
||||||
const fetchMock = vi.fn(async (url: string) => fetchStub(url));
|
|
||||||
const context = vm.createContext({
|
|
||||||
CodemanApp,
|
|
||||||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
|
||||||
localStorage: {
|
|
||||||
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
|
|
||||||
setItem: (k: string, v: string) => store.set(k, v),
|
|
||||||
removeItem: (k: string) => store.delete(k),
|
|
||||||
},
|
|
||||||
document,
|
|
||||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
|
|
||||||
MobileDetection: {},
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
confirm: () => true,
|
|
||||||
fetch: fetchMock,
|
|
||||||
});
|
|
||||||
vm.runInContext(`${constantsJs}\n${panelsJs}\nglobalThis.__exts = FILE_PREVIEW_EXTENSIONS;`, context, {
|
|
||||||
filename: 'panels-ui.js',
|
|
||||||
});
|
|
||||||
|
|
||||||
mountPreviewDom();
|
|
||||||
|
|
||||||
const app = new CodemanApp();
|
|
||||||
app.$ = (id: string) => document.getElementById(id);
|
|
||||||
app._resetFilePreviewEdit = () => {};
|
|
||||||
app._isExternalPreviewPath = () => false;
|
|
||||||
app.formatFileSize = (n: number) => `${n} B`;
|
|
||||||
app.showToast = vi.fn();
|
|
||||||
app.filePreviewContent = '';
|
|
||||||
app._renderMarkdown = vi.fn(() => MARKDOWN_HTML);
|
|
||||||
app._linkifyFilePaths = vi.fn();
|
|
||||||
app._bindResponseViewerInteractions = vi.fn();
|
|
||||||
|
|
||||||
const byId = (id: string) => document.getElementById(id) as HTMLButtonElement;
|
|
||||||
return {
|
|
||||||
app,
|
|
||||||
fetchMock,
|
|
||||||
store,
|
|
||||||
body: byId('filePreviewBody'),
|
|
||||||
exts: (context as { __exts: Set<string> }).__exts,
|
|
||||||
btn: { md: byId('filePreviewMdBtn'), lines: byId('filePreviewLinesBtn'), wrap: byId('filePreviewWrapBtn') },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('file viewer rendered markdown', () => {
|
|
||||||
it('renders .md through the shared markdown pipeline, inert to i18n, with the viewer delegate bound', async () => {
|
|
||||||
const { app, body, btn } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
|
|
||||||
const doc = body.firstElementChild as HTMLElement;
|
|
||||||
expect(doc.matches('.rv-text.file-preview-md[data-i18n-skip]')).toBe(true);
|
|
||||||
expect(doc.querySelector('h1')?.textContent).toBe('Title');
|
|
||||||
// A file, not a chat message: source newlines inside a paragraph are not breaks.
|
|
||||||
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT, { breaks: false });
|
|
||||||
// Identity, not deep equality: DOM nodes are compared by reference here.
|
|
||||||
expect(app._linkifyFilePaths.mock.calls[0][0]).toBe(doc);
|
|
||||||
expect(app._bindResponseViewerInteractions.mock.calls[0][0]).toBe(body);
|
|
||||||
// The source stays what Copy copies.
|
|
||||||
expect(app.filePreviewContent).toBe(MD_CONTENT);
|
|
||||||
// MD is the only toggle that applies to a rendered document; Edit still offered.
|
|
||||||
expect(btn.md.hidden).toBe(false);
|
|
||||||
expect(btn.md.getAttribute('aria-pressed')).toBe('true');
|
|
||||||
expect(btn.lines.hidden).toBe(true);
|
|
||||||
expect(btn.wrap.hidden).toBe(true);
|
|
||||||
expect(document.getElementById('filePreviewEditBtn')!.hidden).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches the route ceiling for markdown and the 500-line cap for other text', async () => {
|
|
||||||
const { app, fetchMock } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
await app.openFilePreview('notes.txt', 's1');
|
|
||||||
|
|
||||||
const urls = fetchMock.mock.calls.map((c) => c[0]);
|
|
||||||
expect(urls[0]).toContain('lines=10000');
|
|
||||||
expect(urls[1]).toContain('lines=500');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rebases relative images and links onto the document directory and leaves the rest alone', async () => {
|
|
||||||
const { app, body } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
|
|
||||||
const local = body.querySelector('img[alt="Alt A"]')!;
|
|
||||||
expect(local.getAttribute('src')).toBe(`/api/sessions/s1/file-raw?path=${encodeURIComponent('docs/img/a.png')}`);
|
|
||||||
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
|
|
||||||
|
|
||||||
const rel = body.querySelector('a.rv-path')!;
|
|
||||||
expect(rel.getAttribute('data-path')).toBe('docs/guide/x.md');
|
|
||||||
expect(rel.getAttribute('href')).toBe('#');
|
|
||||||
expect(rel.hasAttribute('target')).toBe(false);
|
|
||||||
expect(rel.hasAttribute('rel')).toBe(false);
|
|
||||||
|
|
||||||
const anchors = Array.from(body.querySelectorAll('a'));
|
|
||||||
// `..` is collapsed against the document directory, so the title reads
|
|
||||||
// CHANGELOG.md rather than docs/../CHANGELOG.md.
|
|
||||||
const up = anchors.find((a) => a.textContent === 'up')!;
|
|
||||||
expect(up.classList.contains('rv-path')).toBe(true);
|
|
||||||
expect(up.getAttribute('data-path')).toBe('CHANGELOG.md');
|
|
||||||
const fragment = anchors.find((a) => a.textContent === 't')!;
|
|
||||||
expect(fragment.getAttribute('href')).toBe('#top');
|
|
||||||
expect(fragment.classList.contains('rv-path')).toBe(false);
|
|
||||||
const external = anchors.find((a) => a.textContent === 'e')!;
|
|
||||||
expect(external.getAttribute('href')).toBe('https://e.com');
|
|
||||||
expect(external.getAttribute('target')).toBe('_blank');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decodes percent-encoded refs, drops the query, and resolves root-relative refs against the workspace', async () => {
|
|
||||||
const { app, body } = loadApp();
|
|
||||||
// An absolute path in the document's prose, linked by the Response
|
|
||||||
// Viewer's linkifier, which knows nothing of the preview's session.
|
|
||||||
app._linkifyFilePaths.mockImplementation((root: HTMLElement) => {
|
|
||||||
const a = root.ownerDocument.createElement('a');
|
|
||||||
a.className = 'rv-path';
|
|
||||||
a.dataset.path = '/tmp/out/run.log';
|
|
||||||
root.appendChild(a);
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
const src = (alt: string) => body.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
|
|
||||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
|
||||||
|
|
||||||
// Decoded once here, encoded once for the route: never `my%2520image.png`.
|
|
||||||
expect(src('space')).toBe(raw('docs/my image.png'));
|
|
||||||
expect(src('raw')).toBe(raw('docs/raw.png'));
|
|
||||||
// A malformed escape keeps the ref as written.
|
|
||||||
expect(src('bad')).toBe(raw('docs/bad%zz.png'));
|
|
||||||
// Root-relative is the workspace root, as on GitHub; protocol-relative is remote.
|
|
||||||
expect(src('root')).toBe(raw('assets/root.png'));
|
|
||||||
expect(src('protorel')).toBe('//cdn.example.com/p.png');
|
|
||||||
|
|
||||||
const anchors = Array.from(body.querySelectorAll('a'));
|
|
||||||
expect(anchors.find((a) => a.textContent === 'cjk')!.getAttribute('data-path')).toBe('docs/图片/截图.md');
|
|
||||||
expect(anchors.find((a) => a.textContent === 'rootlink')!.getAttribute('data-path')).toBe('docs/root.md');
|
|
||||||
// Every rebased link, and every path the linkifier found in the prose,
|
|
||||||
// names the preview's session, so the delegate opens it in that workspace
|
|
||||||
// even when another tab is active.
|
|
||||||
const rebased = body.querySelectorAll('a.rv-path');
|
|
||||||
expect(rebased.length).toBe(5);
|
|
||||||
for (const a of rebased) expect(a.getAttribute('data-session-id')).toBe('s1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('degrades relative refs of an attachment opened by bare file name instead of resolving them in the workspace', async () => {
|
|
||||||
const { app, body, fetchMock } = loadApp();
|
|
||||||
|
|
||||||
// An attachment card passes the registry's bare file name: the document's
|
|
||||||
// directory is unknown, so `img/a.png` must not become the workspace root's.
|
|
||||||
await app.openFilePreview('report.md', 's1', 'att-1');
|
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[0][0]).toContain('/attachments/att-1/raw');
|
|
||||||
expect(body.innerHTML).not.toContain('file-raw');
|
|
||||||
// Relative and root-relative images are their alt text, as a text node.
|
|
||||||
for (const alt of ['Alt A', 'space', 'raw', 'bad', 'root']) {
|
|
||||||
expect(body.querySelector(`img[alt="${alt}"]`)).toBeNull();
|
|
||||||
expect(body.textContent).toContain(alt);
|
|
||||||
}
|
|
||||||
// Remote images and links keep today's handling.
|
|
||||||
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
|
|
||||||
expect(body.querySelector('img[alt="protorel"]')!.getAttribute('src')).toBe('//cdn.example.com/p.png');
|
|
||||||
// Relative links are unwrapped to their text; fragment and http(s) links stay.
|
|
||||||
expect(body.querySelectorAll('a.rv-path')).toHaveLength(0);
|
|
||||||
const anchors = Array.from(body.querySelectorAll('a')).map((a) => a.textContent);
|
|
||||||
expect(anchors).toEqual(['t', 'e']);
|
|
||||||
for (const text of ['x', 'up', 'cjk', 'rootlink']) expect(body.textContent).toContain(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps resolving refs of an absolute-path attachment against its own directory', async () => {
|
|
||||||
const { app, body } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('/tmp/out/report.md', 's1', 'att-2');
|
|
||||||
|
|
||||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
|
||||||
expect(body.querySelector('img[alt="Alt A"]')!.getAttribute('src')).toBe(raw('/tmp/out/img/a.png'));
|
|
||||||
const rel = Array.from(body.querySelectorAll('a.rv-path')).find((a) => a.textContent === 'x')!;
|
|
||||||
expect(rel.getAttribute('data-path')).toBe('/tmp/out/guide/x.md');
|
|
||||||
expect(rel.getAttribute('data-session-id')).toBe('s1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('degrades an image that fails to load to its alt text', async () => {
|
|
||||||
const { app, body } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
const remote = body.querySelector('img[alt="remote"]')!;
|
|
||||||
remote.dispatchEvent(new dom.window.Event('error'));
|
|
||||||
|
|
||||||
expect(body.querySelector('img[alt="remote"]')).toBeNull();
|
|
||||||
expect(body.textContent).toContain('remote');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('MD toggle flips to per-line source and back without refetching, and persists', async () => {
|
|
||||||
const { app, body, btn, fetchMock, store } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
app.toggleFilePreviewMd();
|
|
||||||
|
|
||||||
const pre = body.firstElementChild as HTMLElement;
|
|
||||||
expect(pre.matches('pre.file-preview-text')).toBe(true);
|
|
||||||
expect(pre.querySelectorAll('.fp-line')).toHaveLength(MD_CONTENT.split('\n').length);
|
|
||||||
expect(pre.textContent).toBe(MD_CONTENT);
|
|
||||||
expect(store.get('codeman:filePreviewMdRendered')).toBe('0');
|
|
||||||
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
|
|
||||||
expect(btn.lines.hidden).toBe(false);
|
|
||||||
expect(btn.wrap.hidden).toBe(false);
|
|
||||||
|
|
||||||
app.toggleFilePreviewMd();
|
|
||||||
expect((body.firstElementChild as HTMLElement).matches('.file-preview-md')).toBe(true);
|
|
||||||
expect(store.get('codeman:filePreviewMdRendered')).toBe('1');
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens as source when the device pref says so', async () => {
|
|
||||||
const { app, body, btn } = loadApp({ 'codeman:filePreviewMdRendered': '0' });
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
|
|
||||||
expect((body.firstElementChild as HTMLElement).matches('pre.file-preview-text')).toBe(true);
|
|
||||||
expect(btn.md.hidden).toBe(false);
|
|
||||||
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('file viewer Lines and Wrap toggles', () => {
|
|
||||||
it('flip classes on the <pre>, persist, and never alter the text', async () => {
|
|
||||||
const { app, body, btn, store } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('notes.txt', 's1');
|
|
||||||
|
|
||||||
const pre = body.firstElementChild as HTMLElement;
|
|
||||||
expect(pre.matches('pre.file-preview-text.wrap:not(.show-lines)')).toBe(true);
|
|
||||||
expect(pre.textContent).toBe(TXT_CONTENT);
|
|
||||||
expect(btn.md.hidden).toBe(true);
|
|
||||||
|
|
||||||
app.toggleFilePreviewLines();
|
|
||||||
expect(pre.classList.contains('show-lines')).toBe(true);
|
|
||||||
expect(store.get('codeman:filePreviewLineNumbers')).toBe('1');
|
|
||||||
expect(btn.lines.getAttribute('aria-pressed')).toBe('true');
|
|
||||||
|
|
||||||
app.toggleFilePreviewWrap();
|
|
||||||
expect(pre.classList.contains('wrap')).toBe(false);
|
|
||||||
expect(store.get('codeman:filePreviewWrap')).toBe('0');
|
|
||||||
expect(btn.wrap.getAttribute('aria-pressed')).toBe('false');
|
|
||||||
// Same element, no re-render: the counter gutter is CSS, not text.
|
|
||||||
expect(body.firstElementChild).toBe(pre);
|
|
||||||
expect(pre.textContent).toBe(TXT_CONTENT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('are hidden for an image and while editing', async () => {
|
|
||||||
const { app, btn, body } = loadApp();
|
|
||||||
|
|
||||||
await app.openFilePreview('shot.png', 's1');
|
|
||||||
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
|
|
||||||
|
|
||||||
await app.openFilePreview('notes.txt', 's1');
|
|
||||||
expect(btn.lines.hidden).toBe(false);
|
|
||||||
await app.enterFilePreviewEdit();
|
|
||||||
expect(body.querySelector('textarea.file-preview-editor')).not.toBeNull();
|
|
||||||
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A vendored UMD build (or sanitize-html.js), evaluated as CommonJS the way the other suites do. */
|
|
||||||
function loadCommonJs<T>(name: string): T {
|
|
||||||
const module: { exports: unknown } = { exports: {} };
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
|
||||||
new Function('module', 'exports', publicFile(name))(module, module.exports);
|
|
||||||
return module.exports as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The SHIPPING pipeline end to end: app.js (`_renderMarkdown` and the Response
|
|
||||||
* Viewer's message builder) with panels-ui.js mixed in, the vendored marked,
|
|
||||||
* and DOMPurify behind the real sanitize-html.js config. `content` is what the
|
|
||||||
* file-content route answers for every path.
|
|
||||||
*/
|
|
||||||
function loadShippingApp(content: string) {
|
|
||||||
const createDOMPurify = loadCommonJs<(win: unknown) => unknown>('vendor/dompurify.min.js');
|
|
||||||
const { createMarkdownSanitizer } = loadCommonJs<{ createMarkdownSanitizer: (dp: unknown) => unknown }>(
|
|
||||||
'sanitize-html.js'
|
|
||||||
);
|
|
||||||
const context = vm.createContext({
|
|
||||||
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
|
||||||
performance,
|
|
||||||
setInterval: vi.fn(),
|
|
||||||
clearInterval: vi.fn(),
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
requestAnimationFrame: vi.fn(),
|
|
||||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
|
||||||
document,
|
|
||||||
NodeFilter: dom.window.NodeFilter,
|
|
||||||
localStorage: { length: 0, key: vi.fn(), getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() },
|
|
||||||
// _sanitizeHtml fails closed without the page's sanitizer, which would make
|
|
||||||
// every assertion below vacuous.
|
|
||||||
window: {
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
sanitizeMarkdownHtml: createMarkdownSanitizer(createDOMPurify(dom.window)),
|
|
||||||
},
|
|
||||||
marked: loadCommonJs('vendor/marked.min.js'),
|
|
||||||
MobileDetection: {},
|
|
||||||
confirm: () => true,
|
|
||||||
fetch: vi.fn(async () =>
|
|
||||||
jsonResponse({
|
|
||||||
success: true,
|
|
||||||
data: { content, totalLines: 2, size: content.length, truncated: false, extension: 'md' },
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
vm.runInContext(
|
|
||||||
`${constantsJs}\n${publicFile('app.js')}\n${panelsJs}\nglobalThis.__CodemanApp = CodemanApp;`,
|
|
||||||
context,
|
|
||||||
{ filename: 'app.js' }
|
|
||||||
);
|
|
||||||
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
|
||||||
|
|
||||||
mountPreviewDom();
|
|
||||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
|
||||||
app.$ = (id: string) => document.getElementById(id);
|
|
||||||
app.sessions = new Map();
|
|
||||||
app.filePreviewContent = '';
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('file viewer markdown line breaks', () => {
|
|
||||||
// A README hard-wrapped at the column limit: one paragraph in the source.
|
|
||||||
const WRAPPED = 'A paragraph hard-wrapped\nat the column limit.';
|
|
||||||
|
|
||||||
it('renders a hard-wrapped paragraph as one paragraph in the file view, while chat keeps a break per newline', async () => {
|
|
||||||
const app = loadShippingApp(`${WRAPPED}\n`);
|
|
||||||
|
|
||||||
await app.openFilePreview('docs/README.md', 's1');
|
|
||||||
const para = document.querySelector('#filePreviewBody .file-preview-md p')!;
|
|
||||||
expect(para, 'the document rendered through marked').not.toBeNull();
|
|
||||||
expect(para.querySelector('br')).toBeNull();
|
|
||||||
expect(para.textContent).toBe(WRAPPED);
|
|
||||||
|
|
||||||
// The Response Viewer renders the same text the chat way, a <br> per newline.
|
|
||||||
const message = app._buildResponseViewerMessage(WRAPPED, 'assistant', 'Claude') as HTMLElement;
|
|
||||||
const chatPara = message.querySelector('.rv-text p')!;
|
|
||||||
expect(chatPara.querySelectorAll('br')).toHaveLength(1);
|
|
||||||
expect(chatPara.textContent).toBe(WRAPPED.replace('\n', ''));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FILE_PREVIEW_EXTENSIONS', () => {
|
|
||||||
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
|
|
||||||
const { exts } = loadApp();
|
|
||||||
expect(exts.has('avif')).toBe(true);
|
|
||||||
expect(exts.has('ico')).toBe(true);
|
|
||||||
expect(exts.has('md')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview The pre-push hook that scripts/postinstall.js installs (scripts/git-hooks.mjs).
|
|
||||||
*
|
|
||||||
* Two properties matter more than the hook's contents, because the older pre-commit
|
|
||||||
* installer gets both wrong and this one must not copy it:
|
|
||||||
* 1. It is MARKER-OWNED: a hook the developer wrote by hand is never overwritten.
|
|
||||||
* 2. The hooks directory is resolved through git, since in a worktree `.git` is a FILE
|
|
||||||
* and `<root>/.git/hooks` does not exist, and it is ONLY ever the repo's own
|
|
||||||
* `<git-common-dir>/hooks`: a `core.hooksPath` elsewhere (typically a global one) is
|
|
||||||
* never written to.
|
|
||||||
*
|
|
||||||
* ⚠️ Every filesystem/git test here runs against THROWAWAY repositories under a temp dir.
|
|
||||||
* Never point the installer at this checkout: its hooks directory is shared with every
|
|
||||||
* worktree of it, including whatever the developer is running right now.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
mkdirSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
realpathSync,
|
|
||||||
rmSync,
|
|
||||||
statSync,
|
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import {
|
|
||||||
PRE_PUSH_CHECKS,
|
|
||||||
PRE_PUSH_MARKER,
|
|
||||||
PRE_PUSH_WATCHED_PATHS,
|
|
||||||
installPrePushHook,
|
|
||||||
planHookInstall,
|
|
||||||
renderPrePushHook,
|
|
||||||
resolveGitHooksDir,
|
|
||||||
} from '../scripts/git-hooks.mjs';
|
|
||||||
|
|
||||||
const repoRoot = resolve(import.meta.dirname, '..');
|
|
||||||
const read = (rel: string) => readFileSync(resolve(repoRoot, rel), 'utf8');
|
|
||||||
|
|
||||||
/** git with no user/system config leaking in (a global core.hooksPath would redirect everything). */
|
|
||||||
const GIT_ENV = {
|
|
||||||
...process.env,
|
|
||||||
GIT_CONFIG_NOSYSTEM: '1',
|
|
||||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
||||||
GIT_AUTHOR_NAME: 'test',
|
|
||||||
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
||||||
GIT_COMMITTER_NAME: 'test',
|
|
||||||
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
||||||
CODEMAN_SKIP_PREPUSH: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = GIT_ENV): string {
|
|
||||||
return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
let scratch: string;
|
|
||||||
beforeAll(() => {
|
|
||||||
scratch = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-git-hooks-')));
|
|
||||||
});
|
|
||||||
afterAll(() => {
|
|
||||||
rmSync(scratch, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
let counter = 0;
|
|
||||||
function newRepo(): string {
|
|
||||||
const dir = join(scratch, `repo-${++counter}`);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
git(dir, ['init', '-q', '-b', 'main']);
|
|
||||||
git(dir, ['commit', '-q', '--allow-empty', '-m', 'init']);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('pre-push hook body', () => {
|
|
||||||
const hook = renderPrePushHook();
|
|
||||||
|
|
||||||
it('carries the ownership marker', () => {
|
|
||||||
expect(hook).toContain(PRE_PUSH_MARKER);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs every configured check through npm, and nothing slow', () => {
|
|
||||||
for (const args of PRE_PUSH_CHECKS) {
|
|
||||||
expect(hook).toContain(`run_check ${args.join(' ')}`);
|
|
||||||
}
|
|
||||||
expect(hook).toContain('npm run --silent "$@"');
|
|
||||||
// The whole point of the tier: the minutes-long suites stay out of a per-push hook.
|
|
||||||
expect(hook).not.toMatch(/\btest:(ci|browser|mobile|perf|all)\b/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is POSIX sh', () => {
|
|
||||||
expect(hook.startsWith('#!/bin/sh\n')).toBe(true);
|
|
||||||
const r = spawnSync('sh', ['-n'], { input: hook });
|
|
||||||
expect(r.status).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pre-push checks match the static CI job', () => {
|
|
||||||
const scripts = JSON.parse(read('package.json')).scripts as Record<string, string>;
|
|
||||||
const ci = read('.github/workflows/ci.yml');
|
|
||||||
|
|
||||||
it.each(PRE_PUSH_CHECKS.map((args) => [args.join(' ')] as const))('%s is a real script that CI runs', (joined) => {
|
|
||||||
const [name] = joined.split(' ');
|
|
||||||
expect(scripts[name], `package.json has no "${name}" script`).toBeTypeOf('string');
|
|
||||||
expect(ci).toContain(`npm run ${joined}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('planHookInstall', () => {
|
|
||||||
const hook = renderPrePushHook();
|
|
||||||
|
|
||||||
it('writes when no hook exists', () => {
|
|
||||||
expect(planHookInstall({ existing: null, next: hook })).toBe('write');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to clobber a hook it does not own', () => {
|
|
||||||
expect(planHookInstall({ existing: '#!/bin/sh\nmake lint\n', next: hook })).toBe('skip-foreign');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes its own hook when the body changed', () => {
|
|
||||||
expect(planHookInstall({ existing: `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, next: hook })).toBe('write');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent when already current', () => {
|
|
||||||
expect(planHookInstall({ existing: hook, next: hook })).toBe('up-to-date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats an empty file as absent rather than foreign', () => {
|
|
||||||
expect(planHookInstall({ existing: ' \n', next: hook })).toBe('write');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveGitHooksDir (temp repos)', () => {
|
|
||||||
// resolveGitHooksDir runs git with process.env, so an exported GIT_CONFIG_GLOBAL or a system
|
|
||||||
// gitconfig carrying core.hooksPath would otherwise redirect every expectation below.
|
|
||||||
// test/setup.ts swaps HOME, which only covers ~/.gitconfig.
|
|
||||||
const ambient = {
|
|
||||||
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
|
|
||||||
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
|
|
||||||
};
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.GIT_CONFIG_NOSYSTEM = '1';
|
|
||||||
process.env.GIT_CONFIG_GLOBAL = '/dev/null';
|
|
||||||
});
|
|
||||||
afterAll(() => {
|
|
||||||
for (const [k, v] of Object.entries(ambient)) {
|
|
||||||
if (v === undefined) delete process.env[k];
|
|
||||||
else process.env[k] = v;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves <root>/.git/hooks in a plain checkout', () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves the SHARED hooks dir from a worktree, where .git is a file', () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
const wt = join(scratch, `wt-${counter}`);
|
|
||||||
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-branch']);
|
|
||||||
expect(statSync(join(wt, '.git')).isFile()).toBe(true);
|
|
||||||
expect(resolveGitHooksDir(wt)).toBe(join(repo, '.git', 'hooks'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null outside any git checkout', () => {
|
|
||||||
const dir = join(scratch, `plain-${++counter}`);
|
|
||||||
mkdirSync(dir);
|
|
||||||
expect(resolveGitHooksDir(dir)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when a repo-local core.hooksPath points outside the repo', () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
const outside = join(scratch, `shared-hooks-${counter}`);
|
|
||||||
mkdirSync(outside);
|
|
||||||
git(repo, ['config', 'core.hooksPath', outside]);
|
|
||||||
expect(resolveGitHooksDir(repo)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when core.hooksPath points at a directory that does not exist yet', () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
git(repo, ['config', 'core.hooksPath', join(scratch, `missing-${counter}`, 'hooks')]);
|
|
||||||
expect(resolveGitHooksDir(repo)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still resolves when core.hooksPath points at the repo's OWN .git/hooks", () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'hooks')]);
|
|
||||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves before .git/hooks exists (compares the would-be path)', () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
rmSync(join(repo, '.git', 'hooks'), { recursive: true, force: true });
|
|
||||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null under a GLOBAL core.hooksPath, from a checkout and from a worktree', () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
const wt = join(scratch, `wt-global-${counter}`);
|
|
||||||
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-global']);
|
|
||||||
const globalHooks = join(scratch, `global-hooks-${counter}`);
|
|
||||||
mkdirSync(globalHooks);
|
|
||||||
const globalConfig = join(scratch, `gitconfig-${counter}`);
|
|
||||||
writeFileSync(globalConfig, `[core]\n\thooksPath = ${globalHooks}\n`);
|
|
||||||
// resolveGitHooksDir runs git with the ambient environment, so scope the fake global
|
|
||||||
// config to this test through process.env (never the developer's real ~/.gitconfig).
|
|
||||||
const saved = {
|
|
||||||
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
|
|
||||||
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
|
|
||||||
};
|
|
||||||
process.env.GIT_CONFIG_GLOBAL = globalConfig;
|
|
||||||
process.env.GIT_CONFIG_NOSYSTEM = '1';
|
|
||||||
try {
|
|
||||||
expect(git(repo, ['rev-parse', '--git-path', 'hooks'], { ...GIT_ENV, GIT_CONFIG_GLOBAL: globalConfig })).toBe(
|
|
||||||
globalHooks
|
|
||||||
);
|
|
||||||
expect(resolveGitHooksDir(repo)).toBeNull();
|
|
||||||
expect(resolveGitHooksDir(wt)).toBeNull();
|
|
||||||
} finally {
|
|
||||||
for (const [k, v] of Object.entries(saved)) {
|
|
||||||
if (v === undefined) delete process.env[k];
|
|
||||||
else process.env[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Control: the same repo resolves again once the global setting is gone.
|
|
||||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for a copy nested inside someone else's repo (e.g. under node_modules)", () => {
|
|
||||||
const repo = newRepo();
|
|
||||||
const nested = join(repo, 'node_modules', 'aicodeman');
|
|
||||||
mkdirSync(nested, { recursive: true });
|
|
||||||
expect(resolveGitHooksDir(nested)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('installPrePushHook (temp repos)', () => {
|
|
||||||
it('writes an executable hook into a fresh repo', () => {
|
|
||||||
const hooks = join(newRepo(), '.git', 'hooks');
|
|
||||||
expect(installPrePushHook(hooks)).toBe('write');
|
|
||||||
const path = join(hooks, 'pre-push');
|
|
||||||
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
|
|
||||||
expect(statSync(path).mode & 0o111).not.toBe(0);
|
|
||||||
expect(installPrePushHook(hooks)).toBe('up-to-date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a foreign pre-push hook byte-identical', () => {
|
|
||||||
const hooks = join(newRepo(), '.git', 'hooks');
|
|
||||||
const path = join(hooks, 'pre-push');
|
|
||||||
const mine = '#!/bin/sh\n# my own hook\nexit 0\n';
|
|
||||||
writeFileSync(path, mine, { mode: 0o755 });
|
|
||||||
expect(installPrePushHook(hooks)).toBe('skip-foreign');
|
|
||||||
expect(readFileSync(path, 'utf8')).toBe(mine);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes a stale managed hook and keeps it executable', () => {
|
|
||||||
const hooks = join(newRepo(), '.git', 'hooks');
|
|
||||||
const path = join(hooks, 'pre-push');
|
|
||||||
writeFileSync(path, `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, { mode: 0o644 });
|
|
||||||
expect(installPrePushHook(hooks)).toBe('write');
|
|
||||||
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
|
|
||||||
expect(statSync(path).mode & 0o111).not.toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drive the rendered hook through a real `git push` to a local bare remote. The repo gets a
|
|
||||||
* stub package.json whose check scripts only record that they ran, so this exercises the
|
|
||||||
* hook's control flow (ref parsing, skips, blocking) without running the real checks.
|
|
||||||
*/
|
|
||||||
describe('the installed hook on a real push (temp repos)', () => {
|
|
||||||
function setup(opts: { failing?: string; nodeModules?: boolean } = {}) {
|
|
||||||
const repo = newRepo();
|
|
||||||
const remote = join(scratch, `remote-${counter}.git`);
|
|
||||||
git(scratch, ['init', '-q', '--bare', remote]);
|
|
||||||
git(repo, ['remote', 'add', 'origin', remote]);
|
|
||||||
const log = join(repo, 'ran.log');
|
|
||||||
const scripts: Record<string, string> = {};
|
|
||||||
for (const [name] of PRE_PUSH_CHECKS) {
|
|
||||||
scripts[name] =
|
|
||||||
name === opts.failing ? `echo ${name} >> ran.log && echo boom-${name} && exit 1` : `echo ${name} >> ran.log`;
|
|
||||||
}
|
|
||||||
writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'hook-fixture', private: true, scripts }));
|
|
||||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\nran.log\n');
|
|
||||||
git(repo, ['add', 'package.json', '.gitignore']);
|
|
||||||
git(repo, ['commit', '-q', '-m', 'fixture']);
|
|
||||||
if (opts.nodeModules !== false) mkdirSync(join(repo, 'node_modules'));
|
|
||||||
installPrePushHook(join(repo, '.git', 'hooks'));
|
|
||||||
chmodSync(join(repo, '.git', 'hooks', 'pre-push'), 0o755);
|
|
||||||
const ran = () => {
|
|
||||||
try {
|
|
||||||
return readFileSync(log, 'utf8').trim().split('\n').filter(Boolean);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const push = (args: string[], env: NodeJS.ProcessEnv = {}) =>
|
|
||||||
spawnSync('git', ['push', ...args], { cwd: repo, env: { ...GIT_ENV, ...env }, encoding: 'utf8' });
|
|
||||||
return { repo, remote, ran, push };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the stubs record: npm appends the args after `--` to the script, so they prove forwarding. */
|
|
||||||
const expectedRuns = PRE_PUSH_CHECKS.map((args) => args.filter((a) => a !== '--').join(' '));
|
|
||||||
|
|
||||||
it('runs every check before a push, in order', () => {
|
|
||||||
const { ran, push } = setup();
|
|
||||||
const r = push(['-q', 'origin', 'main']);
|
|
||||||
expect(r.status, r.stderr + r.stdout).toBe(0);
|
|
||||||
expect(ran()).toEqual(expectedRuns);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks the push when a check fails, but still runs the rest', () => {
|
|
||||||
const { ran, push, remote } = setup({ failing: 'lint' });
|
|
||||||
const r = push(['origin', 'main']);
|
|
||||||
expect(r.status).not.toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
|
|
||||||
expect(r.stdout + r.stderr).toContain('boom-lint');
|
|
||||||
expect(ran()).toEqual(expectedRuns);
|
|
||||||
expect(spawnSync('git', ['rev-parse', '--verify', '-q', 'refs/heads/main'], { cwd: remote }).status).not.toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CODEMAN_SKIP_PREPUSH=1 skips every check', () => {
|
|
||||||
const { ran, push } = setup({ failing: 'lint' });
|
|
||||||
const r = push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' });
|
|
||||||
expect(r.status, r.stderr).toBe(0);
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a delete-only push skips the checks', () => {
|
|
||||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
|
||||||
expect(push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
|
|
||||||
git(repo, ['branch', 'doomed']);
|
|
||||||
expect(push(['-q', 'origin', 'doomed'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
|
|
||||||
const r = push(['-q', 'origin', '--delete', 'doomed']);
|
|
||||||
expect(r.status, r.stderr).toBe(0);
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips when the pushed ref is not the checked-out HEAD', () => {
|
|
||||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
|
||||||
git(repo, ['branch', 'other']);
|
|
||||||
git(repo, ['commit', '-q', '--allow-empty', '-m', 'only on main']);
|
|
||||||
git(repo, ['checkout', '-q', 'other']);
|
|
||||||
// HEAD is `other`; pushing `main` would check a working tree that is not main's.
|
|
||||||
const r = push(['origin', 'main']);
|
|
||||||
expect(r.status, r.stderr).toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain(
|
|
||||||
'pre-push: skipping static checks: refs/heads/main is not the checked-out HEAD'
|
|
||||||
);
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips when any one of several pushed refs is not HEAD', () => {
|
|
||||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
|
||||||
git(repo, ['branch', 'behind']);
|
|
||||||
git(repo, ['commit', '-q', '--allow-empty', '-m', 'ahead']);
|
|
||||||
const r = push(['origin', 'main', 'behind']);
|
|
||||||
expect(r.status, r.stderr).toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain('is not the checked-out HEAD');
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still checks an annotated tag that points at HEAD (the tag is peeled)', () => {
|
|
||||||
const { ran, push, repo } = setup();
|
|
||||||
git(repo, ['tag', '-a', 'v1', '-m', 'v1']);
|
|
||||||
const r = push(['-q', 'origin', 'v1']);
|
|
||||||
expect(r.status, r.stderr + r.stdout).toBe(0);
|
|
||||||
expect(ran()).toEqual(expectedRuns);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
'src/wip.ts',
|
|
||||||
'config/wip.json',
|
|
||||||
'scripts/wip.mjs',
|
|
||||||
'test/wip.test.ts',
|
|
||||||
'install.sh',
|
|
||||||
'tsconfig.json',
|
|
||||||
'.prettierignore',
|
|
||||||
'.editorconfig',
|
|
||||||
])('skips when %s is untracked (another session may own it)', (rel) => {
|
|
||||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
|
||||||
mkdirSync(join(repo, rel, '..'), { recursive: true });
|
|
||||||
writeFileSync(join(repo, rel), 'wip\n');
|
|
||||||
const r = push(['origin', 'main']);
|
|
||||||
expect(r.status, r.stderr).toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain('pre-push: skipping static checks: uncommitted changes under');
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips when a tracked package.json has an unstaged edit', () => {
|
|
||||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
|
||||||
const pkg = join(repo, 'package.json');
|
|
||||||
writeFileSync(pkg, readFileSync(pkg, 'utf8') + '\n');
|
|
||||||
const r = push(['origin', 'main']);
|
|
||||||
expect(r.status, r.stderr).toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain('uncommitted changes under');
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still checks when the only uncommitted changes are outside the watched paths', () => {
|
|
||||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
|
||||||
mkdirSync(join(repo, 'docs'));
|
|
||||||
writeFileSync(join(repo, 'docs', 'notes.md'), 'draft\n');
|
|
||||||
writeFileSync(join(repo, 'README.md'), 'draft\n');
|
|
||||||
const r = push(['origin', 'main']);
|
|
||||||
expect(r.status).not.toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
|
|
||||||
expect(ran()).toEqual(expectedRuns);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('watches exactly the paths the checks read', () => {
|
|
||||||
expect(PRE_PUSH_WATCHED_PATHS).toEqual([
|
|
||||||
'src',
|
|
||||||
'config',
|
|
||||||
'scripts',
|
|
||||||
'test',
|
|
||||||
'package.json',
|
|
||||||
'package-lock.json',
|
|
||||||
'install.sh',
|
|
||||||
'tsconfig.json',
|
|
||||||
'.prettierignore',
|
|
||||||
'.editorconfig',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips (never blocks) when node_modules is absent', () => {
|
|
||||||
const { ran, push } = setup({ failing: 'lint', nodeModules: false });
|
|
||||||
const r = push(['origin', 'main']);
|
|
||||||
expect(r.status, r.stderr).toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain('node_modules missing');
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips (never blocks) when npm is not on PATH, as under a GUI git client', () => {
|
|
||||||
const { ran, push } = setup({ failing: 'lint' });
|
|
||||||
// A PATH holding only what git and the hook need, and no npm/node. Symlinks rather than
|
|
||||||
// the real directories, since /usr/bin usually holds npm right next to git.
|
|
||||||
const bin = join(scratch, `bin-${counter}`);
|
|
||||||
mkdirSync(bin);
|
|
||||||
for (const tool of ['git', 'sh', 'mktemp', 'tail', 'rm', 'cat']) {
|
|
||||||
const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim();
|
|
||||||
expect(found, `${tool} not found on the test PATH`).toMatch(/^\//);
|
|
||||||
symlinkSync(found, join(bin, tool));
|
|
||||||
}
|
|
||||||
expect(spawnSync('sh', ['-c', 'command -v npm'], { env: { PATH: bin } }).status).not.toBe(0);
|
|
||||||
const r = push(['origin', 'main'], { PATH: bin });
|
|
||||||
expect(r.status, r.stderr + r.stdout).toBe(0);
|
|
||||||
expect(r.stdout + r.stderr).toContain('pre-push: npm not on PATH, skipping checks.');
|
|
||||||
expect(ran()).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('postinstall wiring', () => {
|
|
||||||
const postinstall = read('scripts/postinstall.js');
|
|
||||||
|
|
||||||
it('installs the pre-push hook through the shared module', () => {
|
|
||||||
expect(postinstall).toContain("import('./git-hooks.mjs')");
|
|
||||||
expect(postinstall).toContain('installPrePushHook(gitHooksDir)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves the hooks dir through git, so worktrees work', () => {
|
|
||||||
expect(postinstall).toContain('resolveGitHooksDir(');
|
|
||||||
expect(postinstall).not.toContain("join(import.meta.dirname, '..', '.git', 'hooks')");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -122,7 +122,7 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
|||||||
expect(app).not.toContain('earlier output truncated for performance');
|
expect(app).not.toContain('earlier output truncated for performance');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
|
||||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||||
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||||
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||||
@@ -131,13 +131,10 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
|||||||
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
||||||
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
||||||
// decision this guard exists to pin are unchanged.
|
// decision this guard exists to pin are unchanged.
|
||||||
expect(app).toContain(': `/api/sessions/${sessionId}/terminal?full=1`,\n { full: true }');
|
expect(app).toContain('this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true })');
|
||||||
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
||||||
expect(app).toContain("if (session?.mode === 'shell')");
|
expect(app).toContain("if (session?.mode === 'shell')");
|
||||||
// A shell scroll gesture pulls a BOUNDED window of full history; only the
|
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
|
||||||
// button pulls all of it (behaviour pinned in shell-scroll-history-pull.test.ts).
|
|
||||||
expect(app).toContain("const boundedShellPull = !force && session?.mode === 'shell';");
|
|
||||||
expect(app).toContain('`/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`');
|
|
||||||
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
// Port: none (pure model + fake-DOM row builders — no browser, no server).
|
|
||||||
//
|
|
||||||
// The phone overview and the desktop home rail showing an exited agent as
|
|
||||||
// "exited" rather than "idle" (Ark0N/Codeman#446).
|
|
||||||
//
|
|
||||||
// The server publishes `session.paneExit` when the agent inside a local tmux
|
|
||||||
// pane has exited, while `status` stays `idle` or `busy` by design. Part 1 of
|
|
||||||
// #446 taught the tab strip and the rich rail rows to say so; both home screens
|
|
||||||
// still said "idle" beside nothing running. `_mobileOverviewExit()`
|
|
||||||
// (mobile-overview.js) is now the one rule all three surfaces read. It changes
|
|
||||||
// what a row SHOWS and leaves its `state` alone, because `state` picks the
|
|
||||||
// section and the sort order.
|
|
||||||
//
|
|
||||||
// `paneExitLabel()` is lifted out of the shipped app.js rather than restated
|
|
||||||
// here, so a change to what counts as "exited" there reaches these tests.
|
|
||||||
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');
|
|
||||||
|
|
||||||
/** The shipped `paneExitLabel()` from app.js, as source text. */
|
|
||||||
function paneExitLabelSource(): string {
|
|
||||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
||||||
const match = appJs.match(/function paneExitLabel\(paneExit\) \{[\s\S]*?\n\}\n/);
|
|
||||||
if (!match) throw new Error('paneExitLabel() not found in app.js');
|
|
||||||
return match[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeElement(): any {
|
|
||||||
const el: any = {
|
|
||||||
className: '',
|
|
||||||
type: '',
|
|
||||||
title: '',
|
|
||||||
textContent: '',
|
|
||||||
dataset: {},
|
|
||||||
style: {},
|
|
||||||
children: [] as any[],
|
|
||||||
setAttribute() {},
|
|
||||||
appendChild(child: any) {
|
|
||||||
el.children.push(child);
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every className in a fake-DOM subtree, depth first. */
|
|
||||||
function classNames(el: any): string[] {
|
|
||||||
return [el.className, ...(el.children || []).flatMap(classNames)].filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadApp({ withPaneExitLabel = true } = {}) {
|
|
||||||
const CodemanApp = function CodemanApp(this: any) {};
|
|
||||||
const context = vm.createContext({
|
|
||||||
CodemanApp,
|
|
||||||
console,
|
|
||||||
window: { innerWidth: 1512 },
|
|
||||||
document: {
|
|
||||||
documentElement: { getAttribute: () => null },
|
|
||||||
getElementById: () => null,
|
|
||||||
createElement: () => fakeElement(),
|
|
||||||
createElementNS: () => fakeElement(),
|
|
||||||
},
|
|
||||||
MobileDetection: { getDeviceType: () => 'desktop' },
|
|
||||||
});
|
|
||||||
if (withPaneExitLabel) vm.runInContext(paneExitLabelSource(), context, { filename: 'app.js' });
|
|
||||||
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
|
||||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
|
||||||
}
|
|
||||||
const app = new (CodemanApp as any)();
|
|
||||||
app.getSessionName = (session: any) => session.name || session.id.slice(0, 8);
|
|
||||||
app._shortenHomePath = (p: string) => p || '';
|
|
||||||
app.loadAppSettingsFromStorage = () => ({});
|
|
||||||
app.cases = [];
|
|
||||||
app.pendingHooks = new Map();
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXIT_AT = 1_700_000_000_000;
|
|
||||||
|
|
||||||
function sessions(list: Array<Record<string, any>>) {
|
|
||||||
return new Map(list.map((s) => [s.id, { status: 'idle', mode: 'claude', workingDir: '/w', ...s }]));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('the desktop home rail', () => {
|
|
||||||
it('says "exited" for an idle session whose agent exited, and measures from the exit', () => {
|
|
||||||
const app = loadApp();
|
|
||||||
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT }, lastActivityAt: 5 }]);
|
|
||||||
app.sessionOrder = ['gone'];
|
|
||||||
|
|
||||||
const [row] = app.buildHomeSessionRows();
|
|
||||||
expect(row.state).toBe('idle');
|
|
||||||
expect(row.display).toBe('exited');
|
|
||||||
expect(row.pill).toBe('exited');
|
|
||||||
expect(row.since).toEqual({ key: 'exited', at: EXIT_AT });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('draws a neutral row: no idle or working class on the row, the dot or the pill', () => {
|
|
||||||
// A pane whose agent died mid-turn still has `status: 'busy'`, so without
|
|
||||||
// the display class its dot would pulse green beside "exited".
|
|
||||||
const app = loadApp();
|
|
||||||
app.sessions = sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]);
|
|
||||||
app.sessionOrder = ['gone'];
|
|
||||||
|
|
||||||
const [row] = app.buildHomeSessionRows();
|
|
||||||
expect(row.state).toBe('working');
|
|
||||||
const classes = classNames(app._buildHomeSessionRow(row));
|
|
||||||
expect(classes).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
'home-sessions-row home-sessions-row--exited',
|
|
||||||
'home-sessions-dot home-sessions-dot--exited',
|
|
||||||
'home-sessions-pill home-sessions-pill--exited',
|
|
||||||
])
|
|
||||||
);
|
|
||||||
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a pending permission prompt win over the exit', () => {
|
|
||||||
const app = loadApp();
|
|
||||||
app.sessions = sessions([{ id: 'blocked', paneExit: { status: 0, at: EXIT_AT } }]);
|
|
||||||
app.sessionOrder = ['blocked'];
|
|
||||||
app.pendingHooks = new Map([['blocked', new Set(['permission_prompt'])]]);
|
|
||||||
|
|
||||||
const [row] = app.buildHomeSessionRows();
|
|
||||||
expect(row.display).toBe('needs');
|
|
||||||
expect(row.pill).toBe('needs you');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a session without an exit exactly as it was', () => {
|
|
||||||
const app = loadApp();
|
|
||||||
app.sessions = sessions([{ id: 'live', lastActivityAt: 5 }]);
|
|
||||||
app.sessionOrder = ['live'];
|
|
||||||
|
|
||||||
const [row] = app.buildHomeSessionRows();
|
|
||||||
expect(row.display).toBe('idle');
|
|
||||||
expect(row.pill).toBe('idle');
|
|
||||||
expect(row.since).toEqual({ key: 'idle', at: 5 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('degrades to no override when a stale cached app.js lacks paneExitLabel', () => {
|
|
||||||
const app = loadApp({ withPaneExitLabel: false });
|
|
||||||
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT } }]);
|
|
||||||
app.sessionOrder = ['gone'];
|
|
||||||
|
|
||||||
expect(app.buildHomeSessionRows()[0].pill).toBe('idle');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the phone overview', () => {
|
|
||||||
it('says "exited" and keeps the row in its section', () => {
|
|
||||||
const app = loadApp();
|
|
||||||
const model = app.buildMobileOverviewModel({
|
|
||||||
sessions: sessions([
|
|
||||||
{ id: 'gone', paneExit: { status: 137, at: EXIT_AT } },
|
|
||||||
{ id: 'live', lastActivityAt: 5 },
|
|
||||||
]),
|
|
||||||
cases: [],
|
|
||||||
sessionOrder: ['gone', 'live'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const byId = Object.fromEntries(model.current.map((r: any) => [r.id, r]));
|
|
||||||
expect(byId.gone).toMatchObject({ state: 'idle', display: 'exited', pill: 'exited' });
|
|
||||||
expect(byId.gone.since).toEqual({ key: 'exited', at: EXIT_AT });
|
|
||||||
expect(byId.live).toMatchObject({ display: 'idle', pill: 'idle' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('draws a neutral row', () => {
|
|
||||||
const app = loadApp();
|
|
||||||
const model = app.buildMobileOverviewModel({
|
|
||||||
sessions: sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]),
|
|
||||||
cases: [],
|
|
||||||
});
|
|
||||||
app._pendingApprovalForSession = () => null;
|
|
||||||
|
|
||||||
const classes = classNames(app._buildMobileOverviewRow(model.current[0]));
|
|
||||||
expect(classes).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
'mobile-overview-row mobile-overview-row--exited',
|
|
||||||
'mobile-overview-dot mobile-overview-dot--exited',
|
|
||||||
'mobile-overview-pill mobile-overview-pill--exited',
|
|
||||||
])
|
|
||||||
);
|
|
||||||
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the exited pill styles', () => {
|
|
||||||
it('gives both home screens a neutral exited pill', () => {
|
|
||||||
expect(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8')).toMatch(/\.home-sessions-pill--exited \{/);
|
|
||||||
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(/\.mobile-overview-pill--exited \{/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -165,16 +165,6 @@ describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
|
|||||||
expect(sanitize(html).toLowerCase()).not.toContain(tag);
|
expect(sanitize(html).toLowerCase()).not.toContain(tag);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOM clobbering: <img name="app"> makes document.app that image, and inline
|
|
||||||
// onclick="app.…()" handlers resolve `app` on the document before the global,
|
|
||||||
// so a rendered README could break every button until a reload.
|
|
||||||
it('drops name= (marked never emits it; it clobbers document.<name>)', () => {
|
|
||||||
const out = sanitize('<img name="app" src="https://example.com/x.png" alt="x"><a name="app" href="#a">a</a>');
|
|
||||||
expect(out).not.toMatch(/\sname\s*=/i);
|
|
||||||
expect(out).toContain('src="https://example.com/x.png"');
|
|
||||||
expect(out).toContain('href="#a"');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('legitimate markdown-rendered HTML survives', () => {
|
describe('legitimate markdown-rendered HTML survives', () => {
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview The phone header's tab strip must read as live tabs.
|
|
||||||
*
|
|
||||||
* It used to render every inactive tab transparent: grey 11px text floating in
|
|
||||||
* unmarked gaps, a boxed Alt+N digit in each (a phone has no Alt key), names cut
|
|
||||||
* to 50px so a shared `w1-` prefix was most of what showed, and the tab that did
|
|
||||||
* not fit chopped mid-word against the connection dot. On a phone it looked like
|
|
||||||
* a row of disabled labels.
|
|
||||||
*
|
|
||||||
* The fix is four small rules in the phone block of mobile.css, and each has a
|
|
||||||
* way to be silently undone, which is what this file fences:
|
|
||||||
*
|
|
||||||
* - The chip rule is written `:where(.header) .session-tab` so it stays at
|
|
||||||
* (0,1,0). Written `.header .session-tab` it would be (0,2,0), tie with the
|
|
||||||
* per-colour `.session-tab[data-color="red"]` left border in styles.css, and
|
|
||||||
* win on source order (mobile.css loads later): every colour-tagged tab would
|
|
||||||
* lose its identity stripe.
|
|
||||||
* - The edge fade is scroll-DRIVEN (no JS). Its two widths must be registered
|
|
||||||
* with @property to interpolate, and @property is only valid at the top
|
|
||||||
* level: nested inside the phone @media it is dropped, the keyframes stop
|
|
||||||
* interpolating, and the fade snaps between states instead of following the
|
|
||||||
* scroll position.
|
|
||||||
* - `animation` is a shorthand that resets `animation-timeline`, so the
|
|
||||||
* timeline must be declared AFTER it or the fade silently becomes a 0s time
|
|
||||||
* animation.
|
|
||||||
*
|
|
||||||
* Parsed with postcss because the declarations live in nested at-rules. The
|
|
||||||
* rendered result (chips on dark and light skins, the fade at both scroll ends)
|
|
||||||
* was checked in a browser; this is the cheap regression fence. Port: N/A.
|
|
||||||
*/
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import postcss, { type AtRule, type Declaration, type Rule } from 'postcss';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const CSS = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile.css'), 'utf8');
|
|
||||||
const ROOT = postcss.parse(CSS);
|
|
||||||
const PHONE_QUERY = '(max-width: 599px)';
|
|
||||||
|
|
||||||
/** Declarations of the rule matching `selector` inside the phone block (later rules win). */
|
|
||||||
function phoneDeclarations(selector: string): Record<string, string> {
|
|
||||||
const found: Record<string, string> = {};
|
|
||||||
ROOT.walkAtRules('media', (atRule) => {
|
|
||||||
if (atRule.params !== PHONE_QUERY) return;
|
|
||||||
atRule.walkRules((rule: Rule) => {
|
|
||||||
if (!rule.selectors.map((s) => s.trim()).includes(selector)) return;
|
|
||||||
rule.walkDecls((decl: Declaration) => {
|
|
||||||
found[decl.prop] = decl.value.trim();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `@property` rule for `name`, wherever it sits. */
|
|
||||||
function propertyRule(name: string): AtRule | undefined {
|
|
||||||
let hit: AtRule | undefined;
|
|
||||||
ROOT.walkAtRules('property', (atRule) => {
|
|
||||||
if (atRule.params.trim() === name) hit = atRule;
|
|
||||||
});
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function declsOf(node: AtRule | Rule): Record<string, string> {
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
node.each((child) => {
|
|
||||||
if (child.type === 'decl') out[child.prop] = child.value.trim();
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('phone header tab strip', () => {
|
|
||||||
describe('chips', () => {
|
|
||||||
const chip = phoneDeclarations(':where(.header) .session-tab');
|
|
||||||
|
|
||||||
it('gives every header tab a fill and border from the skin control tokens', () => {
|
|
||||||
// Tokens, not literals: the four light skins repaint the header with
|
|
||||||
// --glass-bg and define their own --control-* values.
|
|
||||||
expect(chip.background).toMatch(/^var\(--control-bg/);
|
|
||||||
expect(chip['border-color']).toMatch(/^var\(--control-border/);
|
|
||||||
expect(chip.color).toBe('var(--text)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the chip selector at (0,1,0) so per-colour borders still win', () => {
|
|
||||||
// The lookup above only matches the exact `:where(.header)` spelling, so a
|
|
||||||
// rewrite to `.header .session-tab` leaves it empty and fails here.
|
|
||||||
expect(Object.keys(chip).length).toBeGreaterThan(0);
|
|
||||||
expect(phoneDeclarations('.header .session-tab')).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the Alt+N digit, which a phone has no key for', () => {
|
|
||||||
expect(phoneDeclarations(':where(.header) .session-tab .tab-number').display).toBe('none');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops the empty action container on inactive tabs only', () => {
|
|
||||||
// The active tab's gear and close live in .tab-actions, so the rule must
|
|
||||||
// stay scoped to :not(.active).
|
|
||||||
expect(phoneDeclarations(':where(.header) .session-tab:not(.active) .tab-actions').display).toBe('none');
|
|
||||||
expect(phoneDeclarations(':where(.header) .session-tab .tab-actions')).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves enough name to get past a shared w1- prefix', () => {
|
|
||||||
const maxWidth = Number.parseFloat(phoneDeclarations('.session-tab .tab-name')['max-width'] ?? '');
|
|
||||||
expect(maxWidth).toBeGreaterThanOrEqual(72);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('scroll-driven edge fade', () => {
|
|
||||||
it('registers both fade widths at the top level, as lengths starting at 0px', () => {
|
|
||||||
for (const name of ['--tab-strip-fade-start', '--tab-strip-fade-end']) {
|
|
||||||
const rule = propertyRule(name);
|
|
||||||
expect(rule, `${name} is not registered`).toBeDefined();
|
|
||||||
// Nested in @media it is invalid and silently ignored.
|
|
||||||
expect(rule!.parent?.type, `${name} must be top level`).toBe('root');
|
|
||||||
const d = declsOf(rule!);
|
|
||||||
expect(d.syntax).toBe("'<length>'");
|
|
||||||
expect(d['initial-value']).toBe('0px');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fades only the far edge at the start and only the near edge at the end', () => {
|
|
||||||
let frames: Record<string, Record<string, string>> = {};
|
|
||||||
ROOT.walkAtRules('keyframes', (atRule) => {
|
|
||||||
if (atRule.params.trim() !== 'tab-strip-edge-fade') return;
|
|
||||||
frames = {};
|
|
||||||
atRule.each((node) => {
|
|
||||||
if (node.type !== 'rule') return;
|
|
||||||
for (const sel of node.selectors) frames[sel.trim()] = declsOf(node);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(frames['0%']?.['--tab-strip-fade-start']).toBe('0px');
|
|
||||||
expect(Number.parseFloat(frames['0%']?.['--tab-strip-fade-end'] ?? '0')).toBeGreaterThan(0);
|
|
||||||
expect(frames['100%']?.['--tab-strip-fade-end']).toBe('0px');
|
|
||||||
expect(Number.parseFloat(frames['100%']?.['--tab-strip-fade-start'] ?? '0')).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('masks the header strip behind a scroll-timeline feature check, timeline after the shorthand', () => {
|
|
||||||
let strip: Rule | undefined;
|
|
||||||
ROOT.walkAtRules('media', (media) => {
|
|
||||||
if (media.params !== PHONE_QUERY) return;
|
|
||||||
media.walkAtRules('supports', (supports) => {
|
|
||||||
if (!/animation-timeline:\s*scroll\(\)/.test(supports.params)) return;
|
|
||||||
supports.walkRules((rule) => {
|
|
||||||
if (rule.selectors.map((s) => s.trim()).includes('.header .session-tabs')) strip = rule;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(strip, 'no @supports-gated .header .session-tabs rule in the phone block').toBeDefined();
|
|
||||||
|
|
||||||
const props: string[] = [];
|
|
||||||
const d: Record<string, string> = {};
|
|
||||||
strip!.each((node) => {
|
|
||||||
if (node.type !== 'decl') return;
|
|
||||||
props.push(node.prop);
|
|
||||||
d[node.prop] = node.value.replace(/\s+/g, ' ').trim();
|
|
||||||
});
|
|
||||||
for (const prop of ['mask-image', '-webkit-mask-image']) {
|
|
||||||
expect(d[prop]).toContain('var(--tab-strip-fade-start)');
|
|
||||||
expect(d[prop]).toContain('var(--tab-strip-fade-end)');
|
|
||||||
}
|
|
||||||
expect(d.animation).toContain('tab-strip-edge-fade');
|
|
||||||
expect(d['animation-timeline']).toBe('scroll(self inline)');
|
|
||||||
expect(props.indexOf('animation-timeline')).toBeGreaterThan(props.indexOf('animation'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -88,10 +88,9 @@ describe('Tab Navigation', () => {
|
|||||||
if (tabNameExists) {
|
if (tabNameExists) {
|
||||||
const maxWidth = await getCSSProperty(page, SELECTORS.TAB_NAME, 'max-width');
|
const maxWidth = await getCSSProperty(page, SELECTORS.TAB_NAME, 'max-width');
|
||||||
const maxWidthPx = parseFloat(maxWidth);
|
const maxWidthPx = parseFloat(maxWidth);
|
||||||
// 80px on phones: wide enough to get past a shared `w1-` prefix,
|
// Should be 50px on mobile
|
||||||
// still short enough that several tabs fit the strip.
|
expect(maxWidthPx).toBeLessThanOrEqual(60);
|
||||||
expect(maxWidthPx).toBeLessThanOrEqual(96);
|
expect(maxWidthPx).toBeGreaterThan(0);
|
||||||
expect(maxWidthPx).toBeGreaterThanOrEqual(72);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview The clean-exit sweep (Ark0N/Codeman#446, part 2).
|
|
||||||
*
|
|
||||||
* A session whose agent the user ended with `/exit` is closed the way the X
|
|
||||||
* button closes it, so finished sessions stop piling up on the board. A crashed
|
|
||||||
* agent keeps its row, marked with the exit code.
|
|
||||||
*
|
|
||||||
* Each rule pinned here guards against a specific wrong close:
|
|
||||||
*
|
|
||||||
* 1. **Only an explicit numeric 0 is clean.** On tmux 3.2a a SIGKILLed pane
|
|
||||||
* reports neither a status nor a signal, so an absent status is the
|
|
||||||
* crashed-agent case, not the clean one.
|
|
||||||
* 2. **Two agreeing reads, not one.** A single reading can describe a pane
|
|
||||||
* that is about to be revived.
|
|
||||||
* 3. **Never while a start or attach is in flight.** The dead-pane respawn in
|
|
||||||
* `_setupOrAttachMuxSession()` revives an exited pane on purpose, and the
|
|
||||||
* pane reads as dead until it finishes.
|
|
||||||
* 4. **Only local mux-backed sessions.** A remote session's local pane is its
|
|
||||||
* ssh client, whose death may be a transport drop.
|
|
||||||
*
|
|
||||||
* Port: 3189
|
|
||||||
*/
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Session } from '../src/session.js';
|
|
||||||
import { WebServer } from '../src/web/server.js';
|
|
||||||
import type { PaneExit, SessionRemote } from '../src/types.js';
|
|
||||||
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
|
|
||||||
import {
|
|
||||||
CLEAN_EXIT_CLOSE_REASON,
|
|
||||||
CLEAN_EXIT_CONFIRMING_READS,
|
|
||||||
CLEAN_EXIT_MIN_PANE_LIFETIME_MS,
|
|
||||||
isCleanPaneExit,
|
|
||||||
shouldCloseCleanlyExitedSession,
|
|
||||||
} from '../src/pane-exit-sweep.js';
|
|
||||||
|
|
||||||
const PORT = 3189;
|
|
||||||
const AT = 1_700_000_000_000;
|
|
||||||
|
|
||||||
describe('isCleanPaneExit', () => {
|
|
||||||
it('accepts an explicit status of 0 with no signal', () => {
|
|
||||||
expect(isCleanPaneExit({ status: 0, at: AT })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an absent status, which is how a SIGKILL presents on tmux 3.2a', () => {
|
|
||||||
expect(isCleanPaneExit({ at: AT })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a non-zero status', () => {
|
|
||||||
expect(isCleanPaneExit({ status: 1, at: AT })).toBe(false);
|
|
||||||
expect(isCleanPaneExit({ status: 137, at: AT })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses any reported signal, even beside a 0', () => {
|
|
||||||
expect(isCleanPaneExit({ signal: 9, at: AT })).toBe(false);
|
|
||||||
expect(isCleanPaneExit({ status: 0, signal: 15, at: AT })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an unknown exit', () => {
|
|
||||||
expect(isCleanPaneExit(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shouldCloseCleanlyExitedSession', () => {
|
|
||||||
const clean = {
|
|
||||||
paneExit: { status: 0, at: AT } as PaneExit,
|
|
||||||
confirmingReads: CLEAN_EXIT_CONFIRMING_READS,
|
|
||||||
paneLifecycleInFlight: false,
|
|
||||||
closing: false,
|
|
||||||
paneStartedAt: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
it('keeps a clean exit that lands within the startup window, as a startup failure', () => {
|
|
||||||
const justStarted = AT - CLEAN_EXIT_MIN_PANE_LIFETIME_MS + 1;
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: justStarted, confirmingReads: 9 })).toBe(false);
|
|
||||||
// An exit read during the start itself is earlier than the stamp.
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: AT + 500, confirmingReads: 9 })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes a clean exit once the pane has outlived the startup window', () => {
|
|
||||||
const settled = AT - CLEAN_EXIT_MIN_PANE_LIFETIME_MS;
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: settled })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes a clean exit that enough reads agreed on', () => {
|
|
||||||
expect(CLEAN_EXIT_CONFIRMING_READS).toBe(2);
|
|
||||||
expect(shouldCloseCleanlyExitedSession(clean)).toBe(true);
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 5 })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('waits for the second read', () => {
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 1 })).toBe(false);
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 0 })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a session alone while its pane is being started or revived', () => {
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, paneLifecycleInFlight: true })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not queue a second close for a session already closing', () => {
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, closing: true })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a crashed agent however many reads saw it', () => {
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, paneExit: { status: 137, at: AT }, confirmingReads: 9 })).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
expect(shouldCloseCleanlyExitedSession({ ...clean, paneExit: { at: AT }, confirmingReads: 9 })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the sweep on a real server', () => {
|
|
||||||
// Drives the real `paneExitsUpdated` wiring: the mux reports a reading, the
|
|
||||||
// event fires, and the test checks whether the server closed the session.
|
|
||||||
let server: WebServer | null = null;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
await server?.stop?.();
|
|
||||||
server = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
|
|
||||||
|
|
||||||
/** The mux members `Session` needs to be a local mux-backed session that can be stopped. */
|
|
||||||
const sessionMux = () =>
|
|
||||||
({
|
|
||||||
isAvailable: () => true,
|
|
||||||
killSession: async () => true,
|
|
||||||
}) as unknown as TerminalMultiplexer;
|
|
||||||
|
|
||||||
const addSession = (web: WebServer, extra: Record<string, unknown> = {}) => {
|
|
||||||
const muxName = `codeman-${Math.random().toString(16).slice(2, 10)}`;
|
|
||||||
const session = new Session({
|
|
||||||
workingDir: '/tmp',
|
|
||||||
mode: 'claude',
|
|
||||||
useMux: true,
|
|
||||||
mux: sessionMux(),
|
|
||||||
muxSession: { muxName, sessionId: 'x' } as unknown as MuxSession,
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
(web as unknown as { sessions: Map<string, Session> }).sessions.set(session.id, session);
|
|
||||||
return { session, muxName };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A server whose mux reports `exit` for every pane, seen by `reads` reads. */
|
|
||||||
const build = (exit: PaneExit | undefined, reads: number) => {
|
|
||||||
const web = new WebServer(PORT, false, true);
|
|
||||||
server = web;
|
|
||||||
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
|
|
||||||
const state = { exit, reads };
|
|
||||||
mux.getPaneExit = () => state.exit;
|
|
||||||
mux.getPaneExitReadCount = () => (state.exit ? state.reads : 0);
|
|
||||||
const tick = () => (mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
|
|
||||||
const cleanup = vi
|
|
||||||
.spyOn(web as unknown as { cleanupSession: (...a: unknown[]) => Promise<void> }, 'cleanupSession')
|
|
||||||
.mockResolvedValue(undefined);
|
|
||||||
return { web, state, tick, cleanup };
|
|
||||||
};
|
|
||||||
|
|
||||||
it('closes a cleanly exited session on the second read, with a reason in the lifecycle log', () => {
|
|
||||||
const { web, state, tick, cleanup } = build({ status: 0, at: AT }, 1);
|
|
||||||
const { session } = addSession(web);
|
|
||||||
|
|
||||||
tick();
|
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
|
||||||
// The exit is on the record already, so the tab says "exited (0)" meanwhile.
|
|
||||||
expect(session.paneExit).toEqual({ status: 0, at: AT });
|
|
||||||
|
|
||||||
state.reads = 2;
|
|
||||||
tick();
|
|
||||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
||||||
expect(cleanup).toHaveBeenCalledWith(session.id, true, CLEAN_EXIT_CLOSE_REASON);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a session whose pane started moments before the clean exit', () => {
|
|
||||||
const { web, tick, cleanup } = build({ status: 0, at: AT }, 2);
|
|
||||||
const { session } = addSession(web);
|
|
||||||
(session as unknown as { _paneStartedAt: number })._paneStartedAt = AT - 2_000;
|
|
||||||
|
|
||||||
tick();
|
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
|
||||||
expect(session.paneExit).toEqual({ status: 0, at: AT });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tries each exit once, so a failed close is not retried every tick', () => {
|
|
||||||
const { web, tick, cleanup } = build({ status: 0, at: AT }, 2);
|
|
||||||
const { session } = addSession(web);
|
|
||||||
|
|
||||||
tick();
|
|
||||||
tick();
|
|
||||||
tick();
|
|
||||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
||||||
// The spy resolved without removing the session, which is what a failed
|
|
||||||
// close looks like from here: the row stays, with its exit badge.
|
|
||||||
expect(session.paneExit).toEqual({ status: 0, at: AT });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives a new exit in the same pane its own attempt', () => {
|
|
||||||
const { web, state, tick, cleanup } = build({ status: 0, at: AT }, 2);
|
|
||||||
addSession(web);
|
|
||||||
|
|
||||||
tick();
|
|
||||||
state.exit = { status: 0, at: AT + 60_000 };
|
|
||||||
tick();
|
|
||||||
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a crashed agent on the board', () => {
|
|
||||||
const { web, tick, cleanup } = build({ status: 137, at: AT }, 5);
|
|
||||||
addSession(web);
|
|
||||||
tick();
|
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps an agent whose exit status tmux did not report', () => {
|
|
||||||
const { web, tick, cleanup } = build({ at: AT }, 5);
|
|
||||||
addSession(web);
|
|
||||||
tick();
|
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never closes a remote session, whose local pane is only the ssh client', () => {
|
|
||||||
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
|
|
||||||
addSession(web, { remote });
|
|
||||||
tick();
|
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a session alone while its pane is being revived', () => {
|
|
||||||
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
|
|
||||||
const { session } = addSession(web);
|
|
||||||
vi.spyOn(session, 'paneLifecycleInFlight', 'get').mockReturnValue(true);
|
|
||||||
tick();
|
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes nothing when the mux cannot count its reads', () => {
|
|
||||||
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
|
|
||||||
delete (web as unknown as { mux: Record<string, unknown> }).mux.getPaneExitReadCount;
|
|
||||||
addSession(web);
|
|
||||||
tick();
|
|
||||||
expect(cleanup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the session for real through cleanupSession', async () => {
|
|
||||||
// No spy on cleanupSession here: the close runs the same path as the X
|
|
||||||
// button, and the session leaves the server's map.
|
|
||||||
const web = new WebServer(PORT, false, true);
|
|
||||||
server = web;
|
|
||||||
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
|
|
||||||
mux.getPaneExit = () => ({ status: 0, at: AT });
|
|
||||||
mux.getPaneExitReadCount = () => 2;
|
|
||||||
const { session } = addSession(web);
|
|
||||||
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
|
|
||||||
|
|
||||||
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
|
|
||||||
await vi.waitFor(() => expect(sessions.has(session.id)).toBe(false));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('a session the server is closing', () => {
|
|
||||||
const session = () =>
|
|
||||||
new Session({
|
|
||||||
workingDir: '/tmp',
|
|
||||||
mode: 'shell',
|
|
||||||
useMux: true,
|
|
||||||
mux: { isAvailable: () => true } as unknown as TerminalMultiplexer,
|
|
||||||
muxSession: { muxName: 'codeman-aaaa', sessionId: 'aaaa' } as unknown as MuxSession,
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to start, so a racing start cannot orphan a tmux session', async () => {
|
|
||||||
const s = session();
|
|
||||||
s.markClosing(true);
|
|
||||||
await expect(s.startInteractive()).rejects.toThrow('Session is being closed');
|
|
||||||
await expect(s.startShell()).rejects.toThrow('Session is being closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is cleared again when the server gives the close up', async () => {
|
|
||||||
const web = new WebServer(PORT, false, true);
|
|
||||||
try {
|
|
||||||
const s = session();
|
|
||||||
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
|
|
||||||
sessions.set(s.id, s);
|
|
||||||
const internals = web as unknown as {
|
|
||||||
_doCleanupSession: (...a: unknown[]) => Promise<void>;
|
|
||||||
cleanupSession: (id: string, kill: boolean, reason: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
let closingDuring = false;
|
|
||||||
vi.spyOn(internals, '_doCleanupSession').mockImplementation(async () => {
|
|
||||||
closingDuring = (s as unknown as { _closing: boolean })._closing;
|
|
||||||
throw new Error('layout prune unavailable');
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(internals.cleanupSession(s.id, true, 'test')).rejects.toThrow('layout prune unavailable');
|
|
||||||
expect(closingDuring).toBe(true);
|
|
||||||
expect((s as unknown as { _closing: boolean })._closing).toBe(false);
|
|
||||||
} finally {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
await web.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the pane lifecycle mark on Session', () => {
|
|
||||||
it('is raised for the whole of restartCli and lowered afterwards, even on failure', async () => {
|
|
||||||
let seenDuring: boolean | null = null;
|
|
||||||
let session: Session;
|
|
||||||
const mux = {
|
|
||||||
isAvailable: () => true,
|
|
||||||
muxSessionExists: () => true,
|
|
||||||
respawnPane: async () => {
|
|
||||||
seenDuring = session.paneLifecycleInFlight;
|
|
||||||
throw new Error('respawn failed');
|
|
||||||
},
|
|
||||||
clearPaneExit: () => {},
|
|
||||||
} as unknown as TerminalMultiplexer;
|
|
||||||
session = new Session({
|
|
||||||
workingDir: '/tmp',
|
|
||||||
mode: 'claude',
|
|
||||||
useMux: true,
|
|
||||||
mux,
|
|
||||||
muxSession: { muxName: 'codeman-aaaa', sessionId: 'aaaa' } as unknown as MuxSession,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(session.paneLifecycleInFlight).toBe(false);
|
|
||||||
await expect(session.restartCli()).rejects.toThrow('respawn failed');
|
|
||||||
expect(seenDuring).toBe(true);
|
|
||||||
expect(session.paneLifecycleInFlight).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Deleting a session keeps `.claude-images` while a sibling in the
|
|
||||||
* same working directory is still live (Ark0N/Codeman#446).
|
|
||||||
*
|
|
||||||
* `cleanupSession()` removes `{workingDir}/.claude-images` recursively. That
|
|
||||||
* dir belongs to the working directory, not to the session, and several
|
|
||||||
* sessions routinely share one case directory, so closing one used to delete
|
|
||||||
* the pasted images a live sibling still referred to. The exited-agent sweep
|
|
||||||
* closes sessions unattended, which turns that from an occasional loss into a
|
|
||||||
* routine one.
|
|
||||||
*
|
|
||||||
* Port: 3188
|
|
||||||
*/
|
|
||||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { WebServer } from '../src/web/server.js';
|
|
||||||
import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js';
|
|
||||||
|
|
||||||
const PORT = 3188;
|
|
||||||
|
|
||||||
describe('pasteImageDirInUseByOtherSession', () => {
|
|
||||||
const none = new Set<string>();
|
|
||||||
const check = (
|
|
||||||
live: Array<{ id: string; workingDir: string }>,
|
|
||||||
opts: { persisted?: Array<{ id: string; workingDir: string; status?: string }>; killing?: Set<string> } = {}
|
|
||||||
) =>
|
|
||||||
pasteImageDirInUseByOtherSession({
|
|
||||||
live,
|
|
||||||
persisted: opts.persisted ?? [],
|
|
||||||
closingId: 'a',
|
|
||||||
workingDir: '/work/case',
|
|
||||||
killing: opts.killing ?? none,
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds a live sibling in the same working directory', () => {
|
|
||||||
expect(
|
|
||||||
check([
|
|
||||||
{ id: 'a', workingDir: '/work/case' },
|
|
||||||
{ id: 'b', workingDir: '/work/case' },
|
|
||||||
])
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores the session being closed', () => {
|
|
||||||
expect(check([{ id: 'a', workingDir: '/work/case' }])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores sessions in other directories, including a subdirectory', () => {
|
|
||||||
expect(
|
|
||||||
check([
|
|
||||||
{ id: 'a', workingDir: '/work/case' },
|
|
||||||
{ id: 'b', workingDir: '/work/other' },
|
|
||||||
{ id: 'c', workingDir: '/work/case/sub' },
|
|
||||||
])
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalises a trailing slash and dot segments', () => {
|
|
||||||
expect(check([{ id: 'b', workingDir: '/work/x/../case/' }])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not count a sibling that is being killed too', () => {
|
|
||||||
// Two sessions of one case killed together must not each defer to the
|
|
||||||
// other, or neither removes the dir.
|
|
||||||
expect(check([{ id: 'b', workingDir: '/work/case' }], { killing: new Set(['a', 'b']) })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts a detached session, which left the map but still runs in tmux', () => {
|
|
||||||
// `DELETE ?killMux=false` removes the session from the server's map and
|
|
||||||
// keeps its persisted record and its pane.
|
|
||||||
expect(check([], { persisted: [{ id: 'b', workingDir: '/work/case', status: 'idle' }] })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not count a persisted record demoted to stopped, or the closing session', () => {
|
|
||||||
expect(
|
|
||||||
check([], {
|
|
||||||
persisted: [
|
|
||||||
{ id: 'a', workingDir: '/work/case', status: 'idle' },
|
|
||||||
{ id: 'b', workingDir: '/work/case', status: 'stopped' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches a sibling that reaches the same directory through a symlink', () => {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'codeman-paste-link-'));
|
|
||||||
try {
|
|
||||||
const real = join(root, 'case');
|
|
||||||
mkdirSync(real);
|
|
||||||
symlinkSync(real, join(root, 'current'));
|
|
||||||
expect(
|
|
||||||
pasteImageDirInUseByOtherSession({
|
|
||||||
live: [{ id: 'b', workingDir: join(root, 'current') }],
|
|
||||||
persisted: [],
|
|
||||||
closingId: 'a',
|
|
||||||
workingDir: real,
|
|
||||||
killing: none,
|
|
||||||
})
|
|
||||||
).toBe(true);
|
|
||||||
} finally {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleting a session that shares its working directory', () => {
|
|
||||||
let server: WebServer;
|
|
||||||
let workingDir: string;
|
|
||||||
const base = `http://localhost:${PORT}`;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
workingDir = mkdtempSync(join(tmpdir(), 'codeman-paste-shared-'));
|
|
||||||
server = new WebServer(PORT, false, true);
|
|
||||||
await server.start();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await server.stop();
|
|
||||||
rmSync(workingDir, { recursive: true, force: true });
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
const create = async (): Promise<string> => {
|
|
||||||
const res = await fetch(`${base}/api/sessions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ workingDir }),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
return body.data.session.id as string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = (id: string) => fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
it('keeps the images while a sibling is live, and removes them with the last session', async () => {
|
|
||||||
const first = await create();
|
|
||||||
const second = await create();
|
|
||||||
const imageDir = join(workingDir, '.claude-images');
|
|
||||||
mkdirSync(imageDir, { recursive: true });
|
|
||||||
writeFileSync(join(imageDir, 'paste-1.png'), 'x');
|
|
||||||
|
|
||||||
expect((await remove(first)).status).toBe(200);
|
|
||||||
expect(existsSync(join(imageDir, 'paste-1.png'))).toBe(true);
|
|
||||||
|
|
||||||
expect((await remove(second)).status).toBe(200);
|
|
||||||
expect(existsSync(imageDir)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the images while a sibling is only detached, since it still runs in tmux', async () => {
|
|
||||||
const detached = await create();
|
|
||||||
const deleted = await create();
|
|
||||||
// Persisting a new record is debounced, and a detach cancels the pending
|
|
||||||
// write, so wait for the record a long-running session would already have.
|
|
||||||
const store = (server as unknown as { store: { getSession: (id: string) => unknown } }).store;
|
|
||||||
await vi.waitFor(() => expect(store.getSession(detached)).toBeTruthy(), { timeout: 10_000 });
|
|
||||||
const imageDir = join(workingDir, '.claude-images');
|
|
||||||
mkdirSync(imageDir, { recursive: true });
|
|
||||||
writeFileSync(join(imageDir, 'paste-2.png'), 'x');
|
|
||||||
|
|
||||||
expect((await fetch(`${base}/api/sessions/${detached}?killMux=false`, { method: 'DELETE' })).status).toBe(200);
|
|
||||||
expect((await remove(deleted)).status).toBe(200);
|
|
||||||
expect(existsSync(join(imageDir, 'paste-2.png'))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -108,25 +108,6 @@ describe('which dead sessions may be rebuilt', () => {
|
|||||||
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
|
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never revives a session whose agent the user ended with a clean exit (#446)', () => {
|
|
||||||
// The clean-exit sweep would have closed it, had the power not gone first.
|
|
||||||
const persisted = { exited: persistedSession({ id: 'exited', paneExit: { status: 0, at: NOW - HOUR } }) };
|
|
||||||
const plan = planRebootRestore(['exited'], persisted, () => true);
|
|
||||||
expect(plan.restore).toEqual([]);
|
|
||||||
expect(plan.skipped).toEqual([{ sessionId: 'exited', reason: 'agent-exited' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still offers a crashed agent, and one whose exit status tmux never reported', () => {
|
|
||||||
// Same explicit-0 rule as the sweep: an absent status is unknown, not clean,
|
|
||||||
// and a crash keeps its row on the board, so it stays eligible here too.
|
|
||||||
const persisted = {
|
|
||||||
crashed: persistedSession({ id: 'crashed', paneExit: { status: 137, at: NOW - HOUR } }),
|
|
||||||
killed: persistedSession({ id: 'killed', paneExit: { at: NOW - HOUR } }),
|
|
||||||
};
|
|
||||||
const plan = planRebootRestore(['crashed', 'killed'], persisted, () => true);
|
|
||||||
expect(plan.restore.map((s) => s.sessionId)).toEqual(['crashed', 'killed']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never revives a pane whose PTY-exit breaker had tripped', () => {
|
it('never revives a pane whose PTY-exit breaker had tripped', () => {
|
||||||
const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
|
const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
|
||||||
expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
|
expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
|
||||||
|
|||||||
@@ -145,6 +145,6 @@ describe('response viewer file-path linkifier', () => {
|
|||||||
// either leaves inert paths (no linkify) or dead links (no handler).
|
// either leaves inert paths (no linkify) or dead links (no handler).
|
||||||
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
|
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
|
||||||
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
|
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
|
||||||
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/);
|
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -713,12 +713,6 @@ describe('registry writes are serialized and never clobber a file the reader wou
|
|||||||
}
|
}
|
||||||
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
|
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
|
|
||||||
expect(
|
|
||||||
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
|
|
||||||
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -681,18 +681,6 @@ describe('file-routes', () => {
|
|||||||
expect(body.data.url).toContain('file-raw');
|
expect(body.data.url).toContain('file-raw');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies avif as an image so the viewer renders it instead of dumping bytes', async () => {
|
|
||||||
mockedStat.mockResolvedValue({ size: 1024 } as never);
|
|
||||||
|
|
||||||
const res = await harness.app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=photo.avif`,
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.data.type).toBe('image');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns audio metadata for audio files', async () => {
|
it('returns audio metadata for audio files', async () => {
|
||||||
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
||||||
|
|
||||||
@@ -829,19 +817,6 @@ describe('file-routes', () => {
|
|||||||
expect(res.headers['content-type']).toBe('image/png');
|
expect(res.headers['content-type']).toBe('image/png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serves avif with its image type, since <img> refuses an octet-stream', async () => {
|
|
||||||
const content = Buffer.from('fake avif data');
|
|
||||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
|
||||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
|
||||||
|
|
||||||
const res = await harness.app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=photo.avif`,
|
|
||||||
});
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(res.headers['content-type']).toBe('image/avif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
||||||
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
||||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||||
|
|||||||
@@ -14,12 +14,11 @@
|
|||||||
|
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { Session } from '../../src/session.js';
|
import { Session } from '../../src/session.js';
|
||||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { isPlainPromptInput } from '../../src/web/route-helpers.js';
|
|
||||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||||
|
|
||||||
@@ -156,106 +155,3 @@ describe('POST /api/sessions/:id/input rollback wiring', () => {
|
|||||||
expect(session.shouldApplyInput('c2', 5)).toBe(true);
|
expect(session.shouldApplyInput('c2', 5)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* A plain prompt goes through the mux even when the caller did not say `useMux`.
|
|
||||||
*
|
|
||||||
* Measured on Claude Code 2.1.283: a direct write of `<text>\r` arrives as one burst,
|
|
||||||
* a burst of about a hundred characters is taken as a paste, and its `\r` lands as a
|
|
||||||
* newline in the composer, so a script's prompt sat there unsent while the route
|
|
||||||
* answered 200. The mux path types the text, presses Enter on its own, and arms the
|
|
||||||
* submit verifier.
|
|
||||||
*/
|
|
||||||
describe('POST /api/sessions/:id/input plain-prompt routing', () => {
|
|
||||||
let harness: { app: FastifyInstance; ctx: MockRouteContext };
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
harness = await createEnvelopeHarness();
|
|
||||||
});
|
|
||||||
afterEach(async () => {
|
|
||||||
await harness.app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = (body: Record<string, unknown>) =>
|
|
||||||
harness.app.inject({ method: 'POST', url: '/api/sessions/test-session-1/input', payload: body });
|
|
||||||
const spies = () => {
|
|
||||||
const session = harness.ctx.sessions.get('test-session-1')!;
|
|
||||||
return { session, viaMux: vi.spyOn(session, 'writeViaMux'), direct: vi.spyOn(session, 'write') };
|
|
||||||
};
|
|
||||||
const LONG_PROMPT =
|
|
||||||
'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.\r';
|
|
||||||
|
|
||||||
it('sends a prompt with no useMux through the mux, not as one burst', async () => {
|
|
||||||
const { viaMux, direct } = spies();
|
|
||||||
|
|
||||||
const res = await post({ input: LONG_PROMPT });
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
expect(viaMux).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
|
|
||||||
expect(direct).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('answers only once the mux write is done, so the next frame cannot overtake it', async () => {
|
|
||||||
// The browser's POST fallback sends frames one at a time and waits for each 2xx;
|
|
||||||
// a fire-and-forget write here would let its next keystroke land before the Enter.
|
|
||||||
const { viaMux } = spies();
|
|
||||||
let finished = false;
|
|
||||||
viaMux.mockImplementation(async () => {
|
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
|
||||||
finished = true;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await post({ input: 'ok\r', clientId: 'browser-1', seq: 1 });
|
|
||||||
|
|
||||||
expect(finished).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the direct write when the mux write fails', async () => {
|
|
||||||
const { viaMux, direct } = spies();
|
|
||||||
viaMux.mockResolvedValue(false);
|
|
||||||
|
|
||||||
await post({ input: 'hello\r' });
|
|
||||||
|
|
||||||
expect(direct).toHaveBeenCalledWith('hello\r', { fromUser: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the raw write for an explicit useMux: false', async () => {
|
|
||||||
const { viaMux, direct } = spies();
|
|
||||||
|
|
||||||
await post({ input: LONG_PROMPT, useMux: false });
|
|
||||||
|
|
||||||
expect(direct).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
|
|
||||||
expect(viaMux).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a bare Enter', '\r'],
|
|
||||||
['text with no Enter', 'hello'],
|
|
||||||
['an arrow key', '\x1b[A'],
|
|
||||||
['a bracketed paste frame', '\x1b[200~line one\nline two\x1b[201~'],
|
|
||||||
['a line feed inside', 'line one\nline two\r'],
|
|
||||||
['two Enters', 'hello\r\r'],
|
|
||||||
['a tab', 'a\tb\r'],
|
|
||||||
])('leaves %s on the direct write', async (_label, input) => {
|
|
||||||
const { viaMux, direct } = spies();
|
|
||||||
|
|
||||||
await post({ input });
|
|
||||||
|
|
||||||
expect(direct).toHaveBeenCalledWith(input, { fromUser: true });
|
|
||||||
expect(viaMux).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isPlainPromptInput', () => {
|
|
||||||
it('accepts printable text ending in exactly one carriage return', () => {
|
|
||||||
expect(isPlainPromptInput('run the tests\r')).toBe(true);
|
|
||||||
expect(isPlainPromptInput('ünïcødé and emoji 🚀\r')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses anything carrying another control character', () => {
|
|
||||||
for (const input of ['\r', 'x', 'x\n', 'x\r\n', 'x\r\r', '\x1b[Ax\r', 'a\tb\r', 'x\x7f\r', 'x\u009b\r', '\rx']) {
|
|
||||||
expect(isPlainPromptInput(input), JSON.stringify(input)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -920,53 +920,6 @@ describe('session-routes', () => {
|
|||||||
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
|
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('full reload with a tail (?full=1&tail=) cuts the full capture to its newest bytes, cursor restore intact', async () => {
|
|
||||||
// A Shell scroll-to-top asks for exactly this (`_maybeRefetchFullHistory`):
|
|
||||||
// tmux's whole scrollback, bounded to the tab-switch tail size. The client
|
|
||||||
// relies on all three answers below, so a refactor that dropped the tail on
|
|
||||||
// a full capture (an unbounded pull from an ordinary scroll) or cut off the
|
|
||||||
// closing cursor move (a caret parked below the prompt) must fail here.
|
|
||||||
const tail = 1024 * 1024;
|
|
||||||
const oldestMarker = 'BOUNDED_OLDEST_LINE_00001';
|
|
||||||
const newestMarker = 'BOUNDED_NEWEST_LINE_40000';
|
|
||||||
const rows: string[] = [oldestMarker];
|
|
||||||
for (let i = 2; i < 40_000; i++) rows.push(`shell history line ${String(i).padStart(5, '0')} lorem ipsum`);
|
|
||||||
rows.push(newestMarker);
|
|
||||||
// What formatCursorRestore appends: up from the last row, then the column.
|
|
||||||
const cursorRestore = '\x1b[3A\r\x1b[2C';
|
|
||||||
const fullHistoryCapture = `${rows.join('\r\n')}${cursorRestore}`;
|
|
||||||
expect(fullHistoryCapture.length).toBeGreaterThan(tail);
|
|
||||||
|
|
||||||
harness.ctx._session.mode = 'shell';
|
|
||||||
harness.ctx._session.terminalBuffer = '';
|
|
||||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
|
||||||
opts?.fullHistory ? fullHistoryCapture : 'only the visible frame'
|
|
||||||
);
|
|
||||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
|
||||||
|
|
||||||
const res = await harness.app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${tail}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
// Still the scrollback, not the visible frame a plain `?tail=` gets.
|
|
||||||
expect(captureSpy).toHaveBeenCalledWith(
|
|
||||||
harness.ctx._session.muxName,
|
|
||||||
expect.objectContaining({ fullHistory: true })
|
|
||||||
);
|
|
||||||
expect(body.data.source).toBe('mux-full-history');
|
|
||||||
// Recoverable, not 'capped': Load full history can still bring the rest back.
|
|
||||||
expect(body.data.truncated).toBe(true);
|
|
||||||
expect(body.data.truncationReason).toBe('tail');
|
|
||||||
expect(body.data.fullSize).toBe(fullHistoryCapture.length);
|
|
||||||
expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(tail);
|
|
||||||
expect(body.data.terminalBuffer).toContain(newestMarker);
|
|
||||||
expect(body.data.terminalBuffer).not.toContain(oldestMarker);
|
|
||||||
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||||
// The full-history capture is the rendered form of everything already in
|
// The full-history capture is the rendered form of everything already in
|
||||||
// the byte buffer; prepending the byte history would replay the whole
|
// the byte buffer; prepending the byte history would replay the whole
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
/**
|
|
||||||
* A session whose turn ended waiting for workers it started counts as working.
|
|
||||||
*
|
|
||||||
* The bug this pins: when Claude hands work to background agents or an ultracode
|
|
||||||
* workflow, it ends its turn and closes it with `✻ Waiting for 1 dynamic workflow to
|
|
||||||
* finish` instead of `✻ Brewed for 1m 18s`. The pane goes quiet with the composer up, so
|
|
||||||
* every other signal called the session idle while it was plainly busy, and it resumes
|
|
||||||
* on its own the moment the workers report back.
|
|
||||||
*
|
|
||||||
* The chrome rows below (the closing row, the right-aligned hint, the composer rules, the
|
|
||||||
* footer, the workflow progress row) are verbatim from a live Claude Code 2.1.283 pane on
|
|
||||||
* 2026-09-28 (`tmux -L codeman capture-pane -p`, 64 columns). The prose and the names are
|
|
||||||
* invented.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
||||||
import { Session } from '../src/session.js';
|
|
||||||
import { getCli } from '../src/config/cli-registry/index.js';
|
|
||||||
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
|
|
||||||
import { isAwaitingWorkers, AWAITING_SEARCH_ROWS, IDLE_SILENCE_MS } from '../src/session-activity.js';
|
|
||||||
|
|
||||||
/** The registry's own pattern, which is what every consumer runs. */
|
|
||||||
const CLAUDE_AWAITING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.awaitingLine!)!;
|
|
||||||
|
|
||||||
const RULE = '────────────────────────────────────────────────────────────────';
|
|
||||||
const NAMED_RULE = '──────────────────────────────────────────────────── w1-demo ─';
|
|
||||||
|
|
||||||
/** Everything Claude draws from the composer down while a workflow runs. */
|
|
||||||
const COMPOSER_AND_FOOTER = [
|
|
||||||
NAMED_RULE,
|
|
||||||
'❯ sounds good, go ahead',
|
|
||||||
RULE,
|
|
||||||
' Opus 5.5 (1M context) in:285,618 out:581 ctx:29%',
|
|
||||||
' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← 2 agents',
|
|
||||||
'',
|
|
||||||
' ◯ docs-research ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ ↓ 1.1m',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** A pane whose newest turn closed with `closing`, then the optional hint row. */
|
|
||||||
function frame(closing: string, { hint = true, body = [] as string[] } = {}): string {
|
|
||||||
return [
|
|
||||||
'⏺ The research is running now: three tracks, each checked by',
|
|
||||||
' a second agent.',
|
|
||||||
'',
|
|
||||||
' When it is done I will rewrite the plan.',
|
|
||||||
'',
|
|
||||||
...body,
|
|
||||||
closing,
|
|
||||||
...(hint ? [' 286199 tokens'] : []),
|
|
||||||
...COMPOSER_AND_FOOTER,
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const WAITING_WORKFLOW = frame('✻ Waiting for 1 dynamic workflow to finish');
|
|
||||||
const WAITING_BOTH = frame('✻ Waiting for 2 background agents and 1 dynamic workflow to finish');
|
|
||||||
const WAITING_AGENT = frame('✻ Waiting for 1 background agent to finish', { hint: false });
|
|
||||||
const DONE = frame('✻ Brewed for 1m 18s · done 3:04 PM');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same screen after the workers reported back and the follow-up turn ended. The
|
|
||||||
* waiting row is a snapshot Claude never redraws, so it is STILL on screen, just no longer
|
|
||||||
* the newest row.
|
|
||||||
*/
|
|
||||||
const FOLLOW_UP_DONE = frame('✻ Cooked for 12s', {
|
|
||||||
body: [
|
|
||||||
'✻ Waiting for 1 dynamic workflow to finish',
|
|
||||||
'',
|
|
||||||
'⏺ All three tracks are back. The plan is rewritten and pushed.',
|
|
||||||
'',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isAwaitingWorkers', () => {
|
|
||||||
it('reads the closing row of a turn that handed off to a workflow', () => {
|
|
||||||
expect(isAwaitingWorkers(WAITING_WORKFLOW, CLAUDE_AWAITING, '❯')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads every form Claude builds the row in', () => {
|
|
||||||
expect(isAwaitingWorkers(WAITING_BOTH, CLAUDE_AWAITING, '❯')).toBe(true);
|
|
||||||
expect(isAwaitingWorkers(WAITING_AGENT, CLAUDE_AWAITING, '❯')).toBe(true);
|
|
||||||
expect(isAwaitingWorkers(frame('✻ Waiting for 3 background agents to finish'), CLAUDE_AWAITING, '❯')).toBe(true);
|
|
||||||
expect(isAwaitingWorkers(frame('✻ Waiting for 2 dynamic workflows to finish'), CLAUDE_AWAITING, '❯')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an ordinary turn end alone', () => {
|
|
||||||
expect(isAwaitingWorkers(DONE, CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a stale waiting row once a newer turn has closed below it', () => {
|
|
||||||
// The trap the whole positional walk exists for: matching the words anywhere on the
|
|
||||||
// screen would pin the session busy until they scrolled away.
|
|
||||||
expect(FOLLOW_UP_DONE).toContain('Waiting for 1 dynamic workflow to finish');
|
|
||||||
expect(isAwaitingWorkers(FOLLOW_UP_DONE, CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses the words when the agent wrote them', () => {
|
|
||||||
// Claude's own rows start in column 0; the agent's prose sits behind `⏺ ` or is
|
|
||||||
// indented, so an agent cannot keep itself busy by printing the sentence.
|
|
||||||
expect(isAwaitingWorkers(frame('⏺ ✻ Waiting for 1 background agent to finish'), CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
expect(isAwaitingWorkers(frame(' ✻ Waiting for 1 background agent to finish'), CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says nothing about a screen with no composer on it', () => {
|
|
||||||
const noComposer = WAITING_WORKFLOW.replace('❯ sounds good, go ahead', ' 1. Yes 2. No');
|
|
||||||
expect(isAwaitingWorkers(noComposer, CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
expect(isAwaitingWorkers('', CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
expect(isAwaitingWorkers(null, CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds the composer in the boxed layout too', () => {
|
|
||||||
const boxed = [
|
|
||||||
'✻ Waiting for 1 dynamic workflow to finish',
|
|
||||||
'╭──────────────────────────────────────╮',
|
|
||||||
'│ ❯ │',
|
|
||||||
'╰──────────────────────────────────────╯',
|
|
||||||
' ⏵⏵ bypass permissions on · ← 1 agent',
|
|
||||||
].join('\n');
|
|
||||||
expect(isAwaitingWorkers(boxed, CLAUDE_AWAITING, '❯')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads a coloured capture', () => {
|
|
||||||
const coloured = WAITING_WORKFLOW.replace(
|
|
||||||
'✻ Waiting for 1 dynamic workflow to finish',
|
|
||||||
'\u001b[2m✻\u001b[0m \u001b[2mWaiting for \u001b[1m1\u001b[22m dynamic workflow to finish\u001b[0m'
|
|
||||||
);
|
|
||||||
expect(isAwaitingWorkers(coloured, CLAUDE_AWAITING, '❯')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops looking a few rows above the composer', () => {
|
|
||||||
const farAway = [
|
|
||||||
'✻ Waiting for 1 dynamic workflow to finish',
|
|
||||||
...Array.from({ length: AWAITING_SEARCH_ROWS }, () => ''),
|
|
||||||
...COMPOSER_AND_FOOTER,
|
|
||||||
].join('\n');
|
|
||||||
expect(isAwaitingWorkers(farAway, CLAUDE_AWAITING, '❯')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('survives a pattern handed to it with the global flag set', () => {
|
|
||||||
const global = new RegExp(CLAUDE_AWAITING.source, 'g');
|
|
||||||
expect(isAwaitingWorkers(WAITING_WORKFLOW, global, '❯')).toBe(true);
|
|
||||||
expect(isAwaitingWorkers(WAITING_WORKFLOW, global, '❯')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A composer repaint: the frame Claude ships roughly once a second while working. */
|
|
||||||
const COMPOSER_REPAINT =
|
|
||||||
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
|
|
||||||
|
|
||||||
type SessionInternals = {
|
|
||||||
_handleTerminalOutput(data: string): void;
|
|
||||||
_detectInteractiveActivity(data: string): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function feed(session: Session, data: string): void {
|
|
||||||
const internals = session as unknown as SessionInternals;
|
|
||||||
internals._handleTerminalOutput(data);
|
|
||||||
internals._detectInteractiveActivity(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A session whose mux reports a scripted screen for the pane probe to read. */
|
|
||||||
function withFakePane(read: () => string, mode: 'claude' | 'codex' = 'claude'): Session {
|
|
||||||
const mux = {
|
|
||||||
isAvailable: () => true,
|
|
||||||
capturePaneText: () => read(),
|
|
||||||
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
|
|
||||||
return new Session({
|
|
||||||
workingDir: '/tmp',
|
|
||||||
mode,
|
|
||||||
mux,
|
|
||||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
|
||||||
} as ConstructorParameters<typeof Session>[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run one turn and let it end, which is when the probe reads the screen. */
|
|
||||||
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
feed(session, repaint);
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
}
|
|
||||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Session status while its workers run', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays working when the turn ends waiting for a workflow', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const session = withFakePane(() => WAITING_WORKFLOW);
|
|
||||||
|
|
||||||
runAndSettle(session);
|
|
||||||
|
|
||||||
expect(session.status).toBe('busy');
|
|
||||||
expect(session.isWorking).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('goes idle once the follow-up turn closes, although the old row is still on screen', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const screen = { text: WAITING_WORKFLOW };
|
|
||||||
const session = withFakePane(() => screen.text);
|
|
||||||
runAndSettle(session);
|
|
||||||
expect(session.status).toBe('busy');
|
|
||||||
|
|
||||||
screen.text = FOLLOW_UP_DONE;
|
|
||||||
// The probe keeps re-reading the screen on its own slow cadence while it says busy,
|
|
||||||
// with no PTY output needed to trigger it.
|
|
||||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 10_000);
|
|
||||||
|
|
||||||
expect(session.status).toBe('idle');
|
|
||||||
expect(session.isWorking).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports an ordinary turn end as idle, as before', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const session = withFakePane(() => DONE);
|
|
||||||
|
|
||||||
runAndSettle(session);
|
|
||||||
|
|
||||||
expect(session.status).toBe('idle');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not apply to a CLI whose registry entry declares no awaitingLine', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
expect(getCli('codex')?.capabilities.workDetect?.awaitingLine).toBeUndefined();
|
|
||||||
const codexScreen = ['✻ Waiting for 1 dynamic workflow to finish', '', '› Ask Codex to do anything', ''].join('\n');
|
|
||||||
const session = withFakePane(() => codexScreen, 'codex');
|
|
||||||
|
|
||||||
runAndSettle(session, '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m');
|
|
||||||
|
|
||||||
expect(session.status).toBe('idle');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -163,12 +163,9 @@ describe('the rich row pill of an exited session', () => {
|
|||||||
// which reads `status` and knows nothing about the exit, so without an override
|
// which reads `status` and knows nothing about the exit, so without an override
|
||||||
// the muted dot sat beside a pill saying "idle".
|
// the muted dot sat beside a pill saying "idle".
|
||||||
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
// The exit rule itself is shared with both home screens and lives in
|
const fn = (re: RegExp, name: string) => {
|
||||||
// mobile-overview.js, so it is lifted from there rather than stubbed.
|
const m = appJs.match(re)?.[0];
|
||||||
const overviewJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-overview.js'), 'utf8');
|
if (!m) throw new Error(`${name} not found in app.js`);
|
||||||
const fn = (re: RegExp, name: string, source = appJs) => {
|
|
||||||
const m = source.match(re)?.[0];
|
|
||||||
if (!m) throw new Error(`${name} not found`);
|
|
||||||
return m;
|
return m;
|
||||||
};
|
};
|
||||||
type Row = { state: string; exited: boolean; pill: string; since: { key: string; at: number } | null };
|
type Row = { state: string; exited: boolean; pill: string; since: { key: string; at: number } | null };
|
||||||
@@ -177,7 +174,6 @@ describe('the rich row pill of an exited session', () => {
|
|||||||
return {
|
return {
|
||||||
${fn(/ {2}_sidebarRichPillLabel\(state\) \{[\s\S]*?\n {2}\}/, '_sidebarRichPillLabel')},
|
${fn(/ {2}_sidebarRichPillLabel\(state\) \{[\s\S]*?\n {2}\}/, '_sidebarRichPillLabel')},
|
||||||
${fn(/ {2}_sidebarRichRow\(id, session\) \{[\s\S]*?\n {2}\}/, '_sidebarRichRow')},
|
${fn(/ {2}_sidebarRichRow\(id, session\) \{[\s\S]*?\n {2}\}/, '_sidebarRichRow')},
|
||||||
${fn(/ {2}_mobileOverviewExit\(state, session\) \{[\s\S]*?\n {2}\}/, '_mobileOverviewExit', overviewJs)},
|
|
||||||
_mobileOverviewState(session, hooks) {
|
_mobileOverviewState(session, hooks) {
|
||||||
if (hooks && hooks.has('permission_prompt')) return 'needs';
|
if (hooks && hooks.has('permission_prompt')) return 'needs';
|
||||||
if (hooks && hooks.has('idle_prompt')) return 'waiting';
|
if (hooks && hooks.has('idle_prompt')) return 'waiting';
|
||||||
|
|||||||
@@ -5,10 +5,9 @@
|
|||||||
* `selectSession()` acknowledges the session's idle approval item server-side
|
* `selectSession()` acknowledges the session's idle approval item server-side
|
||||||
* (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is
|
* (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is
|
||||||
* what makes "I checked it" survive a reload and reach the user's other
|
* what makes "I checked it" survive a reload and reach the user's other
|
||||||
* devices. Four call sites are the APP choosing a session rather than the
|
* devices. Three call sites are the APP choosing a session rather than the
|
||||||
* user: the boot restore, a solo (popped-out) window opening its target, a
|
* user: the boot restore, a solo (popped-out) window opening its target, and
|
||||||
* `#session=<id>` link from another page, and the fallback after the active
|
* the fallback after the active session is deleted. Those pass `auto: true`
|
||||||
* session is deleted. Those pass `auto: true`
|
|
||||||
* and must not spend the alert, or a yellow tab would clear itself every time
|
* and must not spend the alert, or a yellow tab would clear itself every time
|
||||||
* the page loaded and the user would never see it.
|
* the page loaded and the user would never see it.
|
||||||
*
|
*
|
||||||
@@ -111,7 +110,7 @@ describe('selectSession acknowledgement gate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('the call sites the app drives itself', () => {
|
describe('the call sites the app drives itself', () => {
|
||||||
// Source guard: these call sites are the reason the flag exists. If a refactor
|
// Source guard: these three are the reason the flag exists. If a refactor
|
||||||
// moves or reformats them, fail loudly rather than silently going back to
|
// moves or reformats them, fail loudly rather than silently going back to
|
||||||
// "every page load clears the user's yellow tab".
|
// "every page load clears the user's yellow tab".
|
||||||
it.each([
|
it.each([
|
||||||
@@ -119,7 +118,6 @@ describe('selectSession acknowledgement gate', () => {
|
|||||||
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
||||||
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
||||||
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
||||||
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true });'],
|
|
||||||
])('%s passes auto: true', (_label, call) => {
|
])('%s passes auto: true', (_label, call) => {
|
||||||
expect(APP_SOURCE).toContain(call);
|
expect(APP_SOURCE).toContain(call);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ describe('watchingLabel', () => {
|
|||||||
'1 MCP task',
|
'1 MCP task',
|
||||||
'1 background dynamic workflow',
|
'1 background dynamic workflow',
|
||||||
'2 remote dynamic workflows',
|
'2 remote dynamic workflows',
|
||||||
|
'1 Artifact comment monitor',
|
||||||
'2 teams',
|
'2 teams',
|
||||||
];
|
];
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
@@ -140,45 +141,6 @@ describe('watchingLabel', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports no watching while the agent waits for comments on an artifact', () => {
|
|
||||||
// An agent that publishes an artifact arms a monitor for its comments and ends its
|
|
||||||
// turn. That monitor waits on the user, so the idle alert has to reach them. The
|
|
||||||
// singular footer is a live capture from 2026-09-25; the plural is assumed.
|
|
||||||
expect(
|
|
||||||
watchingLabel(pane('⏵⏵ bypass permissions on · 1 Artifact comment monitor · ← for agents'), CLAUDE_WATCHING)
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
watchingLabel(pane('⏵⏵ bypass permissions on · 2 Artifact comment monitors · ← for agents'), CLAUDE_WATCHING)
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a comment monitor outrank other background work on the same row', () => {
|
|
||||||
// A shell beside the monitor is still running, but the agent needs the user all the
|
|
||||||
// same, and the chip order on the footer must not decide that. The second row is
|
|
||||||
// the one that needs the `^` in front of the lookahead.
|
|
||||||
expect(
|
|
||||||
watchingLabel(
|
|
||||||
pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact comment monitor · ← for agents'),
|
|
||||||
CLAUDE_WATCHING
|
|
||||||
)
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
watchingLabel(
|
|
||||||
pane('⏵⏵ bypass permissions on · 1 Artifact comment monitor · 1 shell · ← for agents'),
|
|
||||||
CLAUDE_WATCHING
|
|
||||||
)
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still refuses a footer cut off in the middle of the comment monitor', () => {
|
|
||||||
expect(
|
|
||||||
watchingLabel(pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact comment moni…'), CLAUDE_WATCHING)
|
|
||||||
).toBeNull();
|
|
||||||
// Cut before "comment" is complete: the lookahead keys on "Artifact" alone for these.
|
|
||||||
expect(watchingLabel(pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact comm…'), CLAUDE_WATCHING)).toBeNull();
|
|
||||||
expect(watchingLabel(pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact…'), CLAUDE_WATCHING)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says nothing about a pane that is running nothing', () => {
|
it('says nothing about a pane that is running nothing', () => {
|
||||||
expect(watchingLabel(NOTHING_RUNNING, CLAUDE_WATCHING)).toBeNull();
|
expect(watchingLabel(NOTHING_RUNNING, CLAUDE_WATCHING)).toBeNull();
|
||||||
expect(watchingLabel('', CLAUDE_WATCHING)).toBeNull();
|
expect(watchingLabel('', CLAUDE_WATCHING)).toBeNull();
|
||||||
|
|||||||
@@ -1,360 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview A shell pane's scroll-up must reach the history tmux still holds.
|
|
||||||
*
|
|
||||||
* tmux repaints a burst of output instead of scrolling it, so after `cat` of a
|
|
||||||
* file longer than the screen the browser holds about one screen of scrollback
|
|
||||||
* while tmux holds all of it. Other modes recover it by re-pulling `?full=1`
|
|
||||||
* when the wheel reaches the top (`_maybeRefetchFullHistory`, issue #205). Shell
|
|
||||||
* declined that gesture outright to keep a multi-megabyte capture off xterm's
|
|
||||||
* main thread, leaving only the "Load full history" button, and that button
|
|
||||||
* renders only once a replay was truncated. A young shell tab therefore had no
|
|
||||||
* way to scroll back at all.
|
|
||||||
*
|
|
||||||
* The gesture now pulls a BOUNDED window (`?full=1&tail=TERMINAL_TAIL_SIZE`),
|
|
||||||
* the button stays the unbounded path, and a window the browser already holds
|
|
||||||
* in full is not rewritten. Neither is one a browser at xterm's scrollback cap
|
|
||||||
* could never hold, and a window cut from a byte-capped capture is still labelled
|
|
||||||
* recoverable, since Load full history can reach past it.
|
|
||||||
*
|
|
||||||
* ORDER MATTERS: that skip must run BEFORE the downgrade guard. The guard reads
|
|
||||||
* "smaller than the browser" as "tmux has nothing more to give", which is true of
|
|
||||||
* an unbounded capture and false of a window cut at the tail size, so a bounded
|
|
||||||
* window that reached it marked the session exhausted and took Load full history
|
|
||||||
* off the banner while tmux still held the rest. The second block below drives
|
|
||||||
* the real `_setHistoryTruncation` and the real `computeHistoryTruncationNotice`
|
|
||||||
* to pin what the user is actually told.
|
|
||||||
*
|
|
||||||
* The method is extracted from app.js and run in a `vm` against stubs (no jsdom
|
|
||||||
* on this box; see connection-indicator.test.ts), with the REAL row estimators
|
|
||||||
* from terminal-ui.js, which decide both the downgrade and the no-gain skip.
|
|
||||||
*/
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
||||||
const TERMINAL_TAIL_SIZE = 1024 * 1024;
|
|
||||||
|
|
||||||
function methodSource(source: string, method: string): string {
|
|
||||||
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
|
|
||||||
expect(start, `${method} not found`).toBeGreaterThan(-1);
|
|
||||||
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
|
|
||||||
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Real terminal-ui.js mixin, for `_estimateReplayRows` / `_replayWouldShrinkBuffer`. */
|
|
||||||
function loadTerminalMixin(): Record<string, unknown> {
|
|
||||||
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
|
|
||||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
|
||||||
const context = vm.createContext({
|
|
||||||
console,
|
|
||||||
performance,
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
setInterval: vi.fn(),
|
|
||||||
clearInterval: vi.fn(),
|
|
||||||
requestAnimationFrame: vi.fn(),
|
|
||||||
CodemanApp: FakeCodemanApp,
|
|
||||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
||||||
document: { addEventListener: vi.fn() },
|
|
||||||
});
|
|
||||||
vm.runInContext(source, context);
|
|
||||||
return FakeCodemanApp.prototype;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRefetch(): (this: unknown, opts?: { force?: boolean }) => Promise<void> {
|
|
||||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
||||||
const body = methodSource(app, '_maybeRefetchFullHistory');
|
|
||||||
const context = vm.createContext({ performance, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE: 32 * 1024 });
|
|
||||||
return vm.runInContext(`({ ${body} })._maybeRefetchFullHistory`, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The REAL `_setHistoryTruncation`, so the banner state a pull leaves behind is what production would hold. */
|
|
||||||
function loadSetHistoryTruncation(): (this: unknown, sessionId: string, payload?: Record<string, unknown>) => void {
|
|
||||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
|
||||||
const body = methodSource(app, '_setHistoryTruncation');
|
|
||||||
return vm.runInContext(`({ ${body} })._setHistoryTruncation`, vm.createContext({}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The REAL banner decision from constants.js: what the user is told, and whether Load full history is offered. */
|
|
||||||
function loadNotice() {
|
|
||||||
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
|
|
||||||
vm.runInContext(
|
|
||||||
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}\n;globalThis.__notice = computeHistoryTruncationNotice;`,
|
|
||||||
context,
|
|
||||||
{ filename: 'constants.js' }
|
|
||||||
);
|
|
||||||
return (context as { __notice: (s: Record<string, unknown>) => { visible: boolean; canLoadMore: boolean } }).__notice;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mixin = loadTerminalMixin();
|
|
||||||
const refetch = loadRefetch();
|
|
||||||
const setHistoryTruncation = loadSetHistoryTruncation();
|
|
||||||
const computeNotice = loadNotice();
|
|
||||||
const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\r\n');
|
|
||||||
|
|
||||||
/** A `?full=1&tail=` answer whose window was CUT at the tail size: tmux holds ~3 MiB, the window carries 1 MiB. */
|
|
||||||
const TAIL_CUT = {
|
|
||||||
truncated: true,
|
|
||||||
truncationReason: 'tail',
|
|
||||||
fullSize: 3 * 1024 * 1024,
|
|
||||||
retainedBytes: TERMINAL_TAIL_SIZE,
|
|
||||||
source: 'mux-full-history',
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeApp(
|
|
||||||
mode: string,
|
|
||||||
{
|
|
||||||
bufferRows,
|
|
||||||
capture,
|
|
||||||
payload = {},
|
|
||||||
scrollback = 0,
|
|
||||||
}: { bufferRows: number; capture: string; payload?: Record<string, unknown>; scrollback?: number }
|
|
||||||
) {
|
|
||||||
const urls: string[] = [];
|
|
||||||
const app = {
|
|
||||||
activeSessionId: 's1',
|
|
||||||
sessions: new Map([['s1', { mode }]]),
|
|
||||||
detachedSessions: new Set<string>(),
|
|
||||||
_fullHistoryRepullInFlight: false,
|
|
||||||
_isLoadingBuffer: false,
|
|
||||||
_fullHistoryRepullAt: new Map<string, number>(),
|
|
||||||
_fullHistoryRepullUseless: new Set<string>(),
|
|
||||||
terminalBufferCache: new Map<string, string>(),
|
|
||||||
terminal: {
|
|
||||||
cols: 80,
|
|
||||||
rows: 30,
|
|
||||||
// xterm's scrollback option; 0 leaves the browser-cap check out of a test.
|
|
||||||
options: { scrollback },
|
|
||||||
buffer: { active: { length: bufferRows } },
|
|
||||||
scrollToLine: vi.fn(),
|
|
||||||
scrollToTop: vi.fn(),
|
|
||||||
},
|
|
||||||
_estimateReplayRows: mixin._estimateReplayRows,
|
|
||||||
_replayWouldShrinkBuffer: mixin._replayWouldShrinkBuffer,
|
|
||||||
_fetchTerminalCapture: vi.fn(async (url: string) => {
|
|
||||||
urls.push(url);
|
|
||||||
return {
|
|
||||||
headersAt: performance.now(),
|
|
||||||
headers: { get: () => '' },
|
|
||||||
json: { data: { terminalBuffer: capture, source: 'mux-full-history', ...payload } },
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
_recordTerminalLoadTiming: vi.fn(),
|
|
||||||
_logScrollRouting: vi.fn(),
|
|
||||||
// The real method behind a spy, so a test sees both what it was called with
|
|
||||||
// and the banner state (`_historyTruncation`) it leaves behind.
|
|
||||||
_historyTruncation: new Map<string, unknown>(),
|
|
||||||
_renderHistoryTruncationBanner: vi.fn(),
|
|
||||||
_setHistoryTruncation: vi.fn((sessionId: string, p?: Record<string, unknown>): void => {
|
|
||||||
setHistoryTruncation.call(app, sessionId, p);
|
|
||||||
}),
|
|
||||||
_resetTerminalForReplay: vi.fn(),
|
|
||||||
_bufferLoadFinishOpts: vi.fn(() => ({})),
|
|
||||||
chunkedTerminalWrite: vi.fn(async () => ({ parsedAt: performance.now(), bufferLength: 400, completed: true })),
|
|
||||||
_syncStickyScrollBaseline: vi.fn(),
|
|
||||||
};
|
|
||||||
return { app, urls };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('shell scroll-up pulls a bounded window of tmux history', () => {
|
|
||||||
it('a shell scroll gesture requests full history bounded by the tail size', async () => {
|
|
||||||
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
|
|
||||||
await refetch.call(app);
|
|
||||||
expect(urls).toEqual([`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`]);
|
|
||||||
// It then actually replays the recovered history.
|
|
||||||
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
|
|
||||||
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the Load full history button stays unbounded for a shell', async () => {
|
|
||||||
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
|
|
||||||
await refetch.call(app, { force: true });
|
|
||||||
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('other modes keep the unbounded scroll pull', async () => {
|
|
||||||
const { app, urls } = makeApp('claude', { bufferRows: 40, capture: lines(300) });
|
|
||||||
await refetch.call(app);
|
|
||||||
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a bounded window the browser already holds is not rewritten', async () => {
|
|
||||||
// Browser already has every row the window carries: resetting to rewrite
|
|
||||||
// it would jump the viewport on every scroll that outlasts the cooldown.
|
|
||||||
const { app } = makeApp('shell', { bufferRows: 320, capture: lines(300) });
|
|
||||||
await refetch.call(app);
|
|
||||||
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
|
|
||||||
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
|
|
||||||
// Not latched as useless: more output can put more history in tmux.
|
|
||||||
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
|
|
||||||
// Nothing was written, so the banner state is left exactly as it was.
|
|
||||||
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
|
|
||||||
// …but the skip is visible to someone diagnosing "scroll-to-top does nothing".
|
|
||||||
expect(app._logScrollRouting).toHaveBeenCalledWith('repull-skipped-bounded');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a browser at xterm's scrollback cap stops replaying a window it can never hold, and backs off", async () => {
|
|
||||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps 100k lines, so
|
|
||||||
// a 1 MiB window of short lines can carry more rows than the browser ever will.
|
|
||||||
// `windowRows <= rows held` then never comes true, and every scroll-to-top
|
|
||||||
// past the cooldown reset and re-parsed the window. Untruncated on purpose:
|
|
||||||
// the back-off has to come from the full browser, not from `truncated`.
|
|
||||||
const { app } = makeApp('shell', { bufferRows: 40, capture: lines(2000), scrollback: 1000 });
|
|
||||||
|
|
||||||
// The first pull has room to grow, so it replays.
|
|
||||||
await refetch.call(app);
|
|
||||||
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
|
|
||||||
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
|
|
||||||
// xterm kept only the last `scrollback + rows` of the 2000 rows written.
|
|
||||||
app.terminal.buffer.active.length = 1000 + 30;
|
|
||||||
|
|
||||||
// Past the 4 s cooldown: the browser is full, so nothing is replayed and the
|
|
||||||
// session backs off for a minute.
|
|
||||||
app._fullHistoryRepullAt.set('s1', Date.now() - 5000);
|
|
||||||
await refetch.call(app);
|
|
||||||
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
|
|
||||||
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
|
|
||||||
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
|
|
||||||
expect(app._fullHistoryRepullUseless.has('s1')).toBe(true);
|
|
||||||
|
|
||||||
// So a scroll 10 s later does not even ask the server for another capture.
|
|
||||||
app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
|
|
||||||
await refetch.call(app);
|
|
||||||
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
|
|
||||||
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a replayed window cut from a byte-capped capture still offers Load full history', async () => {
|
|
||||||
// The route keeps `truncationReason: 'capped'` through the tail cut when the
|
|
||||||
// full capture exceeded the byte cap. On a bounded window that is not "gone for
|
|
||||||
// good": the unbounded pull behind the button returns up to the cap itself.
|
|
||||||
const capped = { ...TAIL_CUT, truncationReason: 'capped', fullSize: 40 * 1024 * 1024 };
|
|
||||||
const { app } = makeApp('shell', { bufferRows: 40, capture: lines(300), payload: capped });
|
|
||||||
|
|
||||||
await refetch.call(app);
|
|
||||||
|
|
||||||
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
|
|
||||||
expect(app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ truncationReason: 'tail' }));
|
|
||||||
const notice = computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>);
|
|
||||||
expect(notice.visible).toBe(true);
|
|
||||||
expect(notice.canLoadMore).toBe(true);
|
|
||||||
|
|
||||||
// The button's own unbounded pull is the one place 'capped' is the truth.
|
|
||||||
const button = makeApp('shell', { bufferRows: 40, capture: lines(300), payload: capped });
|
|
||||||
await refetch.call(button.app, { force: true });
|
|
||||||
expect(computeNotice(button.app._historyTruncation.get('s1') as Record<string, unknown>).canLoadMore).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('a skipped bounded window never damages the Load full history banner', () => {
|
|
||||||
it('a tail-cut window smaller than the browser is not replayed and never marks the session exhausted', async () => {
|
|
||||||
// The browser holds far more rows than a 1 MiB window carries, and tmux holds
|
|
||||||
// ~3 MiB. The downgrade guard reads that as "tmux has nothing more to give",
|
|
||||||
// which is true of an unbounded capture and false of a window cut at the tail.
|
|
||||||
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
|
|
||||||
// The tab load that put this session on screen left it truncated and recoverable.
|
|
||||||
app._setHistoryTruncation('s1', TAIL_CUT);
|
|
||||||
app._setHistoryTruncation.mockClear();
|
|
||||||
|
|
||||||
await refetch.call(app);
|
|
||||||
|
|
||||||
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
|
|
||||||
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
|
|
||||||
// Not even a relabel: a skipped window writes nothing, banner state included.
|
|
||||||
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
|
|
||||||
const notice = computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>);
|
|
||||||
expect(notice.visible).toBe(true);
|
|
||||||
// "Earlier output is no longer kept" would be a lie: tmux still holds ~2 MiB more.
|
|
||||||
expect(notice.canLoadMore).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a skip right after Load full history leaves the banner as that load set it', async () => {
|
|
||||||
// Load full history replayed everything, so nothing is truncated any more.
|
|
||||||
const afterLoadFullHistory = {
|
|
||||||
truncated: false,
|
|
||||||
fullSize: 3 * 1024 * 1024,
|
|
||||||
retainedBytes: 3 * 1024 * 1024,
|
|
||||||
source: 'mux-full-history',
|
|
||||||
};
|
|
||||||
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
|
|
||||||
app._setHistoryTruncation('s1', afterLoadFullHistory);
|
|
||||||
const before = structuredClone(app._historyTruncation.get('s1'));
|
|
||||||
app._setHistoryTruncation.mockClear();
|
|
||||||
|
|
||||||
await refetch.call(app);
|
|
||||||
|
|
||||||
// Relabelling it from the bounded payload would call a terminal that holds ALL
|
|
||||||
// of the history "the most recent 1.0 MB".
|
|
||||||
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
|
|
||||||
expect(app._historyTruncation.get('s1')).toEqual(before);
|
|
||||||
expect(computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>).visible).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('backs off for a minute after a truncated skip, and keeps the 4 s cooldown after an untruncated one', async () => {
|
|
||||||
// Truncated: the gesture cannot reach anything older than the browser shows, and
|
|
||||||
// every ask costs the server a synchronous capture of the whole history.
|
|
||||||
// Only just larger than the window, so the downgrade guard does not fire here:
|
|
||||||
// the back-off has to come from the skip itself.
|
|
||||||
const cut = makeApp('shell', { bufferRows: 320, capture: lines(300), payload: TAIL_CUT });
|
|
||||||
await refetch.call(cut.app);
|
|
||||||
expect(cut.app._fullHistoryRepullUseless.has('s1')).toBe(true);
|
|
||||||
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Well past 4 s, still inside the minute: no second capture.
|
|
||||||
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
|
|
||||||
await refetch.call(cut.app);
|
|
||||||
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 61_000);
|
|
||||||
await refetch.call(cut.app);
|
|
||||||
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
// Untruncated: it IS all of tmux's history, and the next burst can add to it.
|
|
||||||
const whole = makeApp('shell', { bufferRows: 320, capture: lines(300) });
|
|
||||||
await refetch.call(whole.app);
|
|
||||||
expect(whole.app._fullHistoryRepullUseless.has('s1')).toBe(false);
|
|
||||||
whole.app._fullHistoryRepullAt.set('s1', Date.now() - 5000);
|
|
||||||
await refetch.call(whole.app);
|
|
||||||
expect(whole.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the downgrade guard still refuses an unbounded capture smaller than the browser, and still marks it exhausted', async () => {
|
|
||||||
// Reordering must not weaken the guard it moved above: a repaint-mode pane's
|
|
||||||
// capture really is one frame, and rewriting with it would destroy history.
|
|
||||||
const oneFrame = makeApp('claude', { bufferRows: 300, capture: lines(36) });
|
|
||||||
await refetch.call(oneFrame.app);
|
|
||||||
expect(oneFrame.app._resetTerminalForReplay).not.toHaveBeenCalled();
|
|
||||||
expect(oneFrame.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
|
|
||||||
expect(oneFrame.app._fullHistoryRepullUseless.has('s1')).toBe(true);
|
|
||||||
|
|
||||||
// The button is unbounded too, so the same guard governs it for a shell.
|
|
||||||
const button = makeApp('shell', { bufferRows: 5000, capture: lines(300) });
|
|
||||||
await refetch.call(button.app, { force: true });
|
|
||||||
expect(button.app._resetTerminalForReplay).not.toHaveBeenCalled();
|
|
||||||
expect(button.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('_replayWouldShrinkBuffer takes rows the caller already estimated', () => {
|
|
||||||
const shrink = mixin._replayWouldShrinkBuffer as (this: unknown, capture: string, rows?: number) => boolean;
|
|
||||||
const make = () => ({
|
|
||||||
terminal: { cols: 80, rows: 30, buffer: { active: { length: 200 } } },
|
|
||||||
_estimateReplayRows: vi.fn(mixin._estimateReplayRows as (t: string, c: number) => number),
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not scan the capture again when handed the estimate', () => {
|
|
||||||
const ctx = make();
|
|
||||||
expect(shrink.call(ctx, lines(300), 300)).toBe(false);
|
|
||||||
expect(shrink.call(ctx, lines(300), 5)).toBe(true);
|
|
||||||
expect(ctx._estimateReplayRows).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still estimates for itself when called the old way', () => {
|
|
||||||
const ctx = make();
|
|
||||||
expect(shrink.call(ctx, lines(300))).toBe(false);
|
|
||||||
expect(ctx._estimateReplayRows).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,31 +11,17 @@
|
|||||||
// refresh arriving mid-replay is now coalesced into ONE trailing re-run rather
|
// refresh arriving mid-replay is now coalesced into ONE trailing re-run rather
|
||||||
// than dropped, because the in-flight fetch may predate the drop the new frame
|
// than dropped, because the in-flight fetch may predate the drop the new frame
|
||||||
// reports and no further frame comes to correct stale content.
|
// reports and no further frame comes to correct stale content.
|
||||||
//
|
|
||||||
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
|
||||||
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
|
||||||
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
|
||||||
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js.
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const TERMINAL_CHUNK_SIZE = 32 * 1024;
|
const TERMINAL_CHUNK_SIZE = 32 * 1024;
|
||||||
const TERMINAL_TAIL_SIZE = 1024 * 1024;
|
|
||||||
/** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */
|
|
||||||
let clock = 0;
|
|
||||||
|
|
||||||
type FakeTerminal = {
|
type FakeTerminal = {
|
||||||
write: ReturnType<typeof vi.fn>;
|
write: ReturnType<typeof vi.fn>;
|
||||||
clear: ReturnType<typeof vi.fn>;
|
clear: ReturnType<typeof vi.fn>;
|
||||||
dispose: ReturnType<typeof vi.fn>;
|
dispose: ReturnType<typeof vi.fn>;
|
||||||
scrollToLine: ReturnType<typeof vi.fn>;
|
|
||||||
scrollToTop: ReturnType<typeof vi.fn>;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
options: { scrollback: number };
|
|
||||||
buffer: { active: { type: string; viewportY: number; length: number } };
|
|
||||||
};
|
};
|
||||||
type FakeSocket = {
|
type FakeSocket = {
|
||||||
onopen: unknown;
|
onopen: unknown;
|
||||||
@@ -50,80 +36,43 @@ type PaneUnderTest = {
|
|||||||
_destroyed: boolean;
|
_destroyed: boolean;
|
||||||
_bufferLoading: boolean;
|
_bufferLoading: boolean;
|
||||||
_bufferRefreshPending: boolean;
|
_bufferRefreshPending: boolean;
|
||||||
_historyPullAt: number;
|
|
||||||
_historyPullUseless: boolean;
|
|
||||||
_liveQueue: unknown[] | null;
|
|
||||||
_onWheel: unknown;
|
|
||||||
_wsClosed: boolean;
|
|
||||||
detachedSessions: Set<string> | undefined;
|
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
_loadBuffer(): Promise<void>;
|
_loadBuffer(): Promise<void>;
|
||||||
_refreshBuffer(): void;
|
_refreshBuffer(): void;
|
||||||
_maybeLoadMoreHistory(): void;
|
|
||||||
_pullHistory(): Promise<void>;
|
|
||||||
_onLiveOutput(data: string): void;
|
|
||||||
_onLiveClear(): void;
|
|
||||||
_installWheelListener(): void;
|
|
||||||
_writeDisconnectedMarker(): void;
|
|
||||||
_onSocketClosed(): void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
|
||||||
const rafQueue: Array<() => void> = [];
|
const rafQueue: Array<() => void> = [];
|
||||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
|
||||||
|
|
||||||
function loadSplitTerminalPane() {
|
function loadSplitTerminalPane() {
|
||||||
|
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const src = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
// The primary pane's row estimator, reduced to a line count: the pull only
|
window: {},
|
||||||
// compares it with the pane's own row count.
|
|
||||||
window: {
|
|
||||||
app: { _estimateReplayRows: (text: string) => text.split('\n').length },
|
|
||||||
AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) },
|
|
||||||
},
|
|
||||||
performance: { now: () => clock },
|
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
|
||||||
// The constants.js globals the module reads at call time.
|
// The constants.js globals the module reads at call time.
|
||||||
TERMINAL_CHUNK_SIZE,
|
TERMINAL_CHUNK_SIZE,
|
||||||
TERMINAL_TAIL_SIZE,
|
TERMINAL_TAIL_SIZE: 1024 * 1024,
|
||||||
});
|
});
|
||||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${src}`, context);
|
||||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||||
.SplitTerminalPane;
|
.SplitTerminalPane;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitTerminalPane = loadSplitTerminalPane();
|
const SplitTerminalPane = loadSplitTerminalPane();
|
||||||
|
|
||||||
function makePane(
|
function makePane(mode = 'claude'): PaneUnderTest & { terminal: FakeTerminal } {
|
||||||
mode = 'claude',
|
const pane = new SplitTerminalPane('s1', {}, { mode });
|
||||||
mount: unknown = {},
|
pane.terminal = { write: vi.fn(), clear: vi.fn(), dispose: vi.fn() };
|
||||||
opts: { detachedSessions?: Set<string> } = {}
|
|
||||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
|
||||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
|
||||||
pane.terminal = {
|
|
||||||
// xterm invokes a write's callback once everything before it is parsed.
|
|
||||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
|
||||||
clear: vi.fn(),
|
|
||||||
dispose: vi.fn(),
|
|
||||||
scrollToLine: vi.fn(),
|
|
||||||
scrollToTop: vi.fn(),
|
|
||||||
cols: 80,
|
|
||||||
rows: 30,
|
|
||||||
// xterm keeps at most `scrollback + rows` rows; small here so a test can fill it.
|
|
||||||
options: { scrollback: 1000 },
|
|
||||||
// A pane sitting at the top of a 40-row buffer on the normal screen.
|
|
||||||
buffer: { active: { type: 'normal', viewportY: 0, length: 40 } },
|
|
||||||
};
|
|
||||||
return pane as PaneUnderTest & { terminal: FakeTerminal };
|
return pane as PaneUnderTest & { terminal: FakeTerminal };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowsOf = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n');
|
function jsonResponse(terminalBuffer: string) {
|
||||||
|
return { json: async () => ({ data: { terminalBuffer } }) };
|
||||||
function jsonResponse(terminalBuffer: string, extra: Record<string, unknown> = {}) {
|
|
||||||
return { json: async () => ({ data: { terminalBuffer, ...extra } }) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
@@ -134,15 +83,12 @@ function deferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected');
|
|
||||||
|
|
||||||
/** Lets every microtask the vm-side promise chain queued run. */
|
/** Lets every microtask the vm-side promise chain queued run. */
|
||||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
rafQueue.length = 0;
|
rafQueue.length = 0;
|
||||||
clock = 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane.destroy()', () => {
|
describe('SplitTerminalPane.destroy()', () => {
|
||||||
@@ -286,513 +232,3 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
|||||||
expect(pane.terminal.write).toHaveBeenCalledWith('back');
|
expect(pane.terminal.write).toHaveBeenCalledWith('back');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|
||||||
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const term = pane.terminal;
|
|
||||||
// The replay grows the buffer once xterm has parsed it (the empty write's callback).
|
|
||||||
term.write.mockImplementation((data: string, done?: () => void) => {
|
|
||||||
if (data === '' && done) term.buffer.active.length = 140;
|
|
||||||
done?.();
|
|
||||||
});
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// With a deadline: live output is held for as long as the pull runs, so a
|
|
||||||
// request that never answers would freeze the pane.
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
|
||||||
signal: { timeoutMs: 10_000 },
|
|
||||||
});
|
|
||||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
|
||||||
expect(term.write).toHaveBeenCalledWith(rowsOf(100));
|
|
||||||
// What was row 0 is now 100 rows down (140 - 40): the reader keeps their
|
|
||||||
// place with the recovered history above it, instead of being dropped at the bottom.
|
|
||||||
expect(term.scrollToLine).toHaveBeenCalledWith(100);
|
|
||||||
expect(pane._bufferLoading).toBe(false);
|
|
||||||
expect(pane._liveQueue).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing away from the top, for other modes, or on the alternate screen', async () => {
|
|
||||||
const midScroll = makePane('shell');
|
|
||||||
midScroll.terminal.buffer.active.viewportY = 12;
|
|
||||||
midScroll._maybeLoadMoreHistory();
|
|
||||||
|
|
||||||
// A repaint-mode agent CLI keeps no tmux history to recover.
|
|
||||||
makePane('claude')._maybeLoadMoreHistory();
|
|
||||||
|
|
||||||
// nano/vim/less own the wheel; their screen is not scrollback.
|
|
||||||
const fullScreenApp = makePane('shell');
|
|
||||||
fullScreenApp.terminal.buffer.active.type = 'alternate';
|
|
||||||
fullScreenApp._maybeLoadMoreHistory();
|
|
||||||
|
|
||||||
await settle();
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stands aside for a detached session, mirroring _sendResize()', async () => {
|
|
||||||
// A detached session's own window already owns its PTY size and
|
|
||||||
// scrollback (buildSplitPickerSessions() already refuses to open one).
|
|
||||||
const pane = makePane('shell', {}, { detachedSessions: new Set(['s1']) });
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a flick fires once: overlapping triggers are dropped, then the cooldown holds', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
const startedAt = pane._historyPullAt;
|
|
||||||
// The cooldown is cleared between triggers on purpose, so that only the
|
|
||||||
// in-flight guard can be what drops the overlapping ones.
|
|
||||||
pane._historyPullAt = 0;
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
pane._historyPullAt = 0;
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
pane._historyPullAt = startedAt;
|
|
||||||
|
|
||||||
response.resolve(jsonResponse(rowsOf(100)));
|
|
||||||
await settle();
|
|
||||||
expect(pane._bufferLoading).toBe(false);
|
|
||||||
|
|
||||||
// Nothing in flight any more, so now it is the 4s cooldown alone.
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Once the cooldown lapses a later scroll-to-top may pull again.
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
|
||||||
pane._historyPullAt = Date.now() - 5000;
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a window the pane already holds in full is not rewritten, and is not latched as useless', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// A reset+rewrite here would jump the viewport for no new rows.
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
|
||||||
expect(pane.terminal.scrollToLine).not.toHaveBeenCalled();
|
|
||||||
expect(pane.terminal.scrollToTop).not.toHaveBeenCalled();
|
|
||||||
// The next burst can put more history in tmux than the pane has.
|
|
||||||
expect(pane._historyPullUseless).toBe(false);
|
|
||||||
expect(pane._bufferLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a downgrade, keeping the 4s cooldown when the window is all of tmux history', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
pane.terminal.buffer.active.length = 500;
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
|
||||||
// Untruncated: tmux has nothing older, but the next burst can add history.
|
|
||||||
expect(pane._historyPullUseless).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a truncated window that fits in the pane backs off for a minute', async () => {
|
|
||||||
// Every ask costs the server a capture-pane of the WHOLE history (`tail` is
|
|
||||||
// cut after the capture), and a window cut at the tail size can never reach
|
|
||||||
// anything older than what the pane already shows.
|
|
||||||
const pane = makePane('shell');
|
|
||||||
pane.terminal.buffer.active.length = 500;
|
|
||||||
// Within a screen of what the pane holds, so the old downgrade guard never
|
|
||||||
// latched it: only the truncated-skip rule can back this off.
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(480), { truncated: true, truncationReason: 'tail' }));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
|
||||||
expect(pane._historyPullUseless).toBe(true);
|
|
||||||
|
|
||||||
// Inside the 60s back-off, well past the normal 4s cooldown.
|
|
||||||
pane._historyPullAt = Date.now() - 10_000;
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a pane already at its scrollback cap skips the window and backs off for a minute', async () => {
|
|
||||||
// A 1 MiB window of short lines can carry more rows than xterm will ever hold
|
|
||||||
// (`scrollback + rows`), so `incoming <= rows held` never comes true and every
|
|
||||||
// scroll-to-top would reset and re-parse it.
|
|
||||||
const pane = makePane('shell');
|
|
||||||
pane.terminal.buffer.active.length = 1030;
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5000)));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(rowsOf(5000));
|
|
||||||
expect(pane._historyPullUseless).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a successful replay clears the one-minute back-off', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
pane._historyPullUseless = true;
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100), { truncated: true, truncationReason: 'tail' }));
|
|
||||||
|
|
||||||
void pane._pullHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith(rowsOf(100));
|
|
||||||
expect(pane._historyPullUseless).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds live output during the replay and replays only what arrived after the capture', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const term = pane.terminal;
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
expect(pane._liveQueue).toEqual([]);
|
|
||||||
|
|
||||||
// Arrives before the response does: it is IN the capture already.
|
|
||||||
clock = 1;
|
|
||||||
pane._onLiveOutput('early');
|
|
||||||
expect(term.write).not.toHaveBeenCalledWith('early');
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
|
|
||||||
// three chunks, which leaves the replay mid-write once the fetch lands.
|
|
||||||
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
|
||||||
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
|
|
||||||
clock = 2; // the response arrives: this is the cutoff
|
|
||||||
response.resolve(jsonResponse(bigReplay));
|
|
||||||
await settle();
|
|
||||||
expect(rafQueue).toHaveLength(1);
|
|
||||||
|
|
||||||
// Arrives while the snapshot is still being written: must not land under it.
|
|
||||||
clock = 3;
|
|
||||||
pane._onLiveOutput('late');
|
|
||||||
expect(term.write).not.toHaveBeenCalledWith('late');
|
|
||||||
|
|
||||||
rafQueue.shift()!();
|
|
||||||
rafQueue.shift()!();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const written = term.write.mock.calls.map((call) => call[0]);
|
|
||||||
expect(written).not.toContain('early');
|
|
||||||
expect(written.at(-1)).toBe('late');
|
|
||||||
expect(pane._liveQueue).toBeNull();
|
|
||||||
expect(pane._bufferLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes every held frame when the pull ends without replaying', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
pane._onLiveOutput('held');
|
|
||||||
await settle();
|
|
||||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// Nothing replaced the terminal, so the frame is news even though it
|
|
||||||
// arrived before the response did.
|
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
pane._onLiveOutput('held');
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane._bufferLoading).toBe(false);
|
|
||||||
expect(pane._liveQueue).toBeNull();
|
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
|
||||||
pane._onLiveOutput('after');
|
|
||||||
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a refresh frame during the pull runs once behind it', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed'));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
pane._refreshBuffer();
|
|
||||||
expect(pane.terminal.clear).not.toHaveBeenCalled();
|
|
||||||
expect(pane._bufferRefreshPending).toBe(true);
|
|
||||||
|
|
||||||
response.resolve(jsonResponse(rowsOf(30)));
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith('refreshed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a clear frame during the pull is queued in order, never applied under the replay', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const term = pane.terminal;
|
|
||||||
const order: string[] = [];
|
|
||||||
term.write.mockImplementation((data: string, done?: () => void) => {
|
|
||||||
order.push(`write:${data}`);
|
|
||||||
done?.();
|
|
||||||
});
|
|
||||||
term.clear.mockImplementation(() => order.push('clear'));
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
pane._onLiveOutput('before');
|
|
||||||
pane._onLiveClear();
|
|
||||||
pane._onLiveOutput('after');
|
|
||||||
// Held: clearing now would wipe a half-written snapshot.
|
|
||||||
expect(order).toEqual([]);
|
|
||||||
|
|
||||||
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(order).toEqual(['write:before', 'clear', 'write:after']);
|
|
||||||
expect(pane._liveQueue).toBeNull();
|
|
||||||
|
|
||||||
// With nothing in flight a clear frame applies straight away.
|
|
||||||
pane._onLiveClear();
|
|
||||||
expect(order.at(-1)).toBe('clear');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a clear that arrived before the capture is not replayed after it', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const term = pane.terminal;
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
clock = 1;
|
|
||||||
pane._onLiveClear(); // already reflected in the capture
|
|
||||||
clock = 2;
|
|
||||||
response.resolve(jsonResponse(rowsOf(100)));
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(term.write).toHaveBeenCalledWith('\x1bc');
|
|
||||||
expect(term.clear).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const term = pane.terminal;
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
pane._onLiveOutput('held');
|
|
||||||
pane.destroy();
|
|
||||||
response.resolve(jsonResponse(rowsOf(100)));
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane._bufferLoading).toBe(false);
|
|
||||||
expect(pane._liveQueue).toBeNull();
|
|
||||||
expect(pane.terminal).toBeNull();
|
|
||||||
expect(term.write).not.toHaveBeenCalledWith('\x1bc');
|
|
||||||
expect(term.write).not.toHaveBeenCalledWith('held');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
fetchMock.mockRejectedValueOnce(new Error('The operation timed out'));
|
|
||||||
|
|
||||||
pane._maybeLoadMoreHistory();
|
|
||||||
pane._onLiveOutput('held');
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane._bufferLoading).toBe(false);
|
|
||||||
expect(pane._liveQueue).toBeNull();
|
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith('held');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => {
|
|
||||||
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
|
||||||
const pane = makePane('shell', mount);
|
|
||||||
fetchMock.mockResolvedValue(jsonResponse(rowsOf(100)));
|
|
||||||
|
|
||||||
pane._installWheelListener();
|
|
||||||
|
|
||||||
// Capture phase: xterm's own wheel handler stopPropagation()s the events it
|
|
||||||
// consumes, so a bubbling listener would never fire while the pane still has
|
|
||||||
// scrollback to scroll, and the pull would work only from the exact top row.
|
|
||||||
const [type, listener, options] = mount.addEventListener.mock.calls[0];
|
|
||||||
expect(type).toBe('wheel');
|
|
||||||
expect(options).toEqual({ capture: true, passive: true });
|
|
||||||
|
|
||||||
listener({ deltaY: 120 }); // wheel down
|
|
||||||
listener({ deltaY: 0 });
|
|
||||||
await settle();
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
listener({ deltaY: -120 }); // wheel up, at the top
|
|
||||||
await settle();
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('destroy() detaches exactly the wheel listener it registered', () => {
|
|
||||||
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
|
||||||
const pane = makePane('shell', mount);
|
|
||||||
pane._installWheelListener();
|
|
||||||
const registered = mount.addEventListener.mock.calls[0][1];
|
|
||||||
|
|
||||||
pane.destroy();
|
|
||||||
|
|
||||||
expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', registered, { capture: true });
|
|
||||||
expect(pane._onWheel).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('connect() installs the wheel listener (static guard)', () => {
|
|
||||||
// connect() needs a whole xterm to run, so its wiring is pinned by source
|
|
||||||
// rather than executed; the listener's behaviour is exercised above.
|
|
||||||
const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()'));
|
|
||||||
expect(connect).toContain('this._installWheelListener();');
|
|
||||||
expect(connect).toContain('this._onLiveClear();');
|
|
||||||
expect(connect).not.toContain('this.terminal.clear();');
|
|
||||||
// The tests below drive the close through _onSocketClosed() directly.
|
|
||||||
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a close with no pull running writes the marker straight away', () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
|
|
||||||
pane._onSocketClosed();
|
|
||||||
|
|
||||||
expect(pane._wsClosed).toBe(true);
|
|
||||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
|
|
||||||
expect(isMarker(pane.terminal.write.mock.calls[0][0])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-stamps the disconnected marker after a replay if the socket closed before the pull started', async () => {
|
|
||||||
// onclose already wrote the marker once; a replay's own `\x1bc` would wipe
|
|
||||||
// it and paint a fresh, current-looking history while onData keeps
|
|
||||||
// silently dropping every keystroke on the dead socket.
|
|
||||||
const pane = makePane('shell');
|
|
||||||
pane._wsClosed = true;
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
|
||||||
|
|
||||||
void pane._pullHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const marker = expect.stringContaining('Pane B disconnected');
|
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
|
||||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes the disconnected marker once, after the replay, if the socket closes mid-fetch', async () => {
|
|
||||||
// The close lands while the capture is in flight, so the HTTP pull still
|
|
||||||
// succeeds (a Codeman restart drops the WS while the tmux session, and so
|
|
||||||
// the pull, survives) and the replay that follows is what the marker must
|
|
||||||
// end up below.
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
const pull = pane._pullHistory();
|
|
||||||
pane._onSocketClosed(); // the close arrives mid-fetch, before the response
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
|
||||||
response.resolve(jsonResponse(rowsOf(100)));
|
|
||||||
await pull;
|
|
||||||
|
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
|
||||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
|
||||||
expect(isMarker(writes.at(-1))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['a skip', 40, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)))],
|
|
||||||
['a downgrade', 500, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)))],
|
|
||||||
['a failed fetch', 40, () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
|
|
||||||
])(
|
|
||||||
'a close mid-fetch that ends in %s writes the marker last, after the held frames',
|
|
||||||
async (_label, rowsHeld, mockFetch) => {
|
|
||||||
// No replay ever runs here, so nothing would wipe a marker written at the
|
|
||||||
// close; written straight away it sat ABOVE the output the pull was still
|
|
||||||
// holding, which the finally block then flushed underneath it.
|
|
||||||
const pane = makePane('shell');
|
|
||||||
pane.terminal.buffer.active.length = rowsHeld;
|
|
||||||
mockFetch();
|
|
||||||
|
|
||||||
const pull = pane._pullHistory();
|
|
||||||
pane._onLiveOutput('frame-A');
|
|
||||||
pane._onLiveOutput('frame-B');
|
|
||||||
pane._onSocketClosed();
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
|
||||||
await pull;
|
|
||||||
|
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
|
||||||
expect(writes.slice(0, 2)).toEqual(['frame-A', 'frame-B']);
|
|
||||||
expect(writes).toHaveLength(3);
|
|
||||||
expect(isMarker(writes[2])).toBe(true);
|
|
||||||
expect(pane._liveQueue).toBeNull();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it('a close during the chunked replay writes exactly one marker, at the end', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
const response = deferred<ReturnType<typeof jsonResponse>>();
|
|
||||||
fetchMock.mockReturnValueOnce(response.promise);
|
|
||||||
|
|
||||||
const pull = pane._pullHistory();
|
|
||||||
// Three chunks, so the replay is still mid-write once the fetch lands.
|
|
||||||
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
|
|
||||||
response.resolve(jsonResponse(bigReplay));
|
|
||||||
await settle();
|
|
||||||
expect(rafQueue).toHaveLength(1);
|
|
||||||
|
|
||||||
// Written now, the marker would land between two chunks of recovered history.
|
|
||||||
pane._onSocketClosed();
|
|
||||||
rafQueue.shift()!();
|
|
||||||
rafQueue.shift()!();
|
|
||||||
await pull;
|
|
||||||
|
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
|
||||||
expect(writes[0]).toBe('\x1bc');
|
|
||||||
expect(writes.filter(isMarker)).toHaveLength(1);
|
|
||||||
expect(isMarker(writes.at(-1))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not re-stamp the marker when the socket is still open', async () => {
|
|
||||||
const pane = makePane('shell');
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
|
|
||||||
|
|
||||||
void pane._pullHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
for (const call of pane.terminal.write.mock.calls) {
|
|
||||||
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not re-stamp the marker when the pull never replayed (skip/downgrade path)', async () => {
|
|
||||||
// Nothing erased the marker in this path, so re-stamping it would be a
|
|
||||||
// second, redundant write.
|
|
||||||
const pane = makePane('shell');
|
|
||||||
pane._wsClosed = true;
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); // held in full already: no replay
|
|
||||||
|
|
||||||
void pane._pullHistory();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pane.terminal.write).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -47,13 +47,11 @@ function loadTerminalUiHarness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A Claude session whose local buffer holds exactly one screen (baseY 0). */
|
/** A Claude session whose local buffer holds exactly one screen (baseY 0). */
|
||||||
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
|
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number } = {}) {
|
||||||
const { app, logs } = loadTerminalUiHarness();
|
const { app, logs } = loadTerminalUiHarness();
|
||||||
const sent: Array<{ id: string; data: string }> = [];
|
const sent: Array<{ id: string; data: string }> = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion }]]);
|
||||||
['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion, cliMouseTracking: overrides.cliMouseTracking }],
|
|
||||||
]);
|
|
||||||
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -113,20 +111,12 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
|
|||||||
// Anchor on the open paren, not the full empty signature: the method takes
|
// Anchor on the open paren, not the full empty signature: the method takes
|
||||||
// options since #258 ({ force }) and this guard is about ORDER, not arity.
|
// options since #258 ({ force }) and this guard is about ORDER, not arity.
|
||||||
const start = source.indexOf('async _maybeRefetchFullHistory(');
|
const start = source.indexOf('async _maybeRefetchFullHistory(');
|
||||||
// Also anchored on the open paren: the guard is handed the rows the caller
|
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start);
|
||||||
// already estimated, and this test is about ORDER, not the argument list.
|
|
||||||
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer', start);
|
|
||||||
const boundedSkip = source.indexOf('boundedShellPull && (windowRows <= rowsNow || browserFull)', start);
|
|
||||||
const reset = source.indexOf('this._resetTerminalForReplay()', start);
|
const reset = source.indexOf('this._resetTerminalForReplay()', start);
|
||||||
|
|
||||||
expect(start).toBeGreaterThan(-1);
|
expect(start).toBeGreaterThan(-1);
|
||||||
expect(guard).toBeGreaterThan(start);
|
expect(guard).toBeGreaterThan(start);
|
||||||
expect(guard).toBeLessThan(reset); // refuse first, only then reset+rewrite
|
expect(guard).toBeLessThan(reset); // refuse first, only then reset+rewrite
|
||||||
// A bounded shell window is skipped BEFORE the guard sees it: the guard reads
|
|
||||||
// "smaller than the browser" as "tmux has nothing more", which a window cut at
|
|
||||||
// the tail size does not mean (see shell-scroll-history-pull.test.ts).
|
|
||||||
expect(boundedSkip).toBeGreaterThan(start);
|
|
||||||
expect(boundedSkip).toBeLessThan(guard);
|
|
||||||
// A hollow pane must also stop re-fetching megabytes on every scroll-up.
|
// A hollow pane must also stop re-fetching megabytes on every scroll-up.
|
||||||
expect(source).toContain('this._fullHistoryRepullUseless');
|
expect(source).toContain('this._fullHistoryRepullUseless');
|
||||||
expect(source).toContain('this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000');
|
expect(source).toContain('this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000');
|
||||||
@@ -196,8 +186,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
// "Wheel scrolls local history" ON pins the wheel to a buffer that, for a
|
// "Wheel scrolls local history" ON pins the wheel to a buffer that, for a
|
||||||
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
|
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
|
||||||
// on 1.11.x would have ended up with a completely dead wheel on 1.12.0.
|
// on 1.11.x would have ended up with a completely dead wheel on 1.12.0.
|
||||||
// Version and tracking both qualify, so the opt-out is the only thing saying no.
|
const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223' }); // gate would forward…
|
||||||
const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward…
|
|
||||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||||||
|
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
|
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
|
||||||
@@ -239,19 +228,10 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => {
|
|||||||
expect(logs[0]).toContain('cliVersion=2.1.100');
|
expect(logs[0]).toContain('cliVersion=2.1.100');
|
||||||
expect(logs[0]).toContain('localScrollbackOptOut=false');
|
expect(logs[0]).toContain('localScrollbackOptOut=false');
|
||||||
expect(logs[0]).toContain('mouseTracking=none');
|
expect(logs[0]).toContain('mouseTracking=none');
|
||||||
// The gate's real tracking input: xterm's own mode above is always 'none'
|
|
||||||
// for Claude, since the server strips the DECSETs.
|
|
||||||
expect(logs[0]).toContain('cliMouseTracking=false');
|
|
||||||
|
|
||||||
app._logScrollRouting('page-keys'); // a changed route still prints
|
app._logScrollRouting('page-keys'); // a changed route still prints
|
||||||
expect(logs).toHaveLength(2);
|
expect(logs).toHaveLength(2);
|
||||||
expect(logs[1]).toContain('page-keys');
|
expect(logs[1]).toContain('page-keys');
|
||||||
|
|
||||||
// The CLI turning tracking on changes the gate, so it prints again.
|
|
||||||
app.sessions.get('sess-1').cliMouseTracking = true;
|
|
||||||
app._logScrollRouting('page-keys');
|
|
||||||
expect(logs).toHaveLength(3);
|
|
||||||
expect(logs[2]).toContain('cliMouseTracking=true');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
|
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
|
||||||
|
|||||||
@@ -585,7 +585,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
it('wheel: forwards to the app for verified sessions without Shift, at ANY scroll position', () => {
|
it('wheel: forwards to the app for verified sessions without Shift, at ANY scroll position', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187', cliMouseTracking: true }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
modes: { mouseTrackingMode: 'none' },
|
modes: { mouseTrackingMode: 'none' },
|
||||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||||
@@ -638,7 +638,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||||
};
|
};
|
||||||
const withVersion = (cliVersion?: string) => {
|
const withVersion = (cliVersion?: string) => {
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion, cliMouseTracking: true }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion }]]);
|
||||||
return app._shouldForwardWheelToApp({ shiftKey: false });
|
return app._shouldForwardWheelToApp({ shiftKey: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -650,25 +650,6 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
expect(withVersion('garbage')).toBe(false); // unparseable → assume older
|
expect(withVersion('garbage')).toBe(false); // unparseable → assume older
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wheel: inline claude (no mouse tracking) keeps the local wheel', () => {
|
|
||||||
// Claude 2.1.280's default inline renderer never enables mouse tracking and
|
|
||||||
// keeps its transcript in real scrollback, so SGR wheel reports are ignored.
|
|
||||||
// Forwarding there made every swipe dead on iOS Safari while codex scrolled.
|
|
||||||
const { app } = loadTerminalUiHarness();
|
|
||||||
app.activeSessionId = 'sess-1';
|
|
||||||
app.terminal = {
|
|
||||||
modes: { mouseTrackingMode: 'none' },
|
|
||||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
|
||||||
};
|
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.280' }]]);
|
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.280', cliMouseTracking: false }]]);
|
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
|
||||||
// Fullscreen (CLAUDE_CODE_NO_FLICKER=1) turns tracking on → forwarding resumes.
|
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.280', cliMouseTracking: true }]]);
|
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wheel: only claude forwards — codex and gemini keep the local wheel', () => {
|
it('wheel: only claude forwards — codex and gemini keep the local wheel', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
@@ -683,19 +664,17 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
// (the codex transcript lives there — inline viewport, no in-app pager) sat unused.
|
// (the codex transcript lives there — inline viewport, no in-app pager) sat unused.
|
||||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||||
// Tracking on and a high version, so only the mode check can say no: without
|
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9' }]]); // no version rescues it
|
||||||
// them the gate is false for claude too and this would pin nothing.
|
|
||||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9', cliMouseTracking: true }]]); // no version rescues it
|
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||||
|
|
||||||
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9', cliMouseTracking: true }]]); // unverified TUI
|
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI
|
||||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
|
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187', cliMouseTracking: true }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
modes: { mouseTrackingMode: 'none' },
|
modes: { mouseTrackingMode: 'none' },
|
||||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||||
|
|||||||
@@ -1126,37 +1126,6 @@ describe('TmuxManager pane-exit bookkeeping', () => {
|
|||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||||
manager.clearPaneExit('codeman-aaaa');
|
manager.clearPaneExit('codeman-aaaa');
|
||||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The clean-exit sweep closes a session only once two reads agreed on its
|
|
||||||
// exit (Ark0N/Codeman#446), so the count must rise only on an exact repeat.
|
|
||||||
it('counts the reads that agreed on one exit', () => {
|
|
||||||
const manager = new TmuxManager();
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts the count again when the status or the pane pid changes', () => {
|
|
||||||
const manager = new TmuxManager();
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|1|'), NOW + 4000));
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|1|'), NOW + 6000));
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops the count once a read sees the pane alive again', () => {
|
|
||||||
const manager = new TmuxManager();
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|0|||'), NOW + 2000));
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
|
|
||||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|0|'), NOW + 4000));
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1245,20 +1214,6 @@ describe('the pane-exit watcher tick', () => {
|
|||||||
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW });
|
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not count an empty read as confirming an exit', async () => {
|
|
||||||
// The clean-exit sweep closes on the second agreeing read. A read tmux did
|
|
||||||
// not answer agrees with nothing, so it must not supply that second read.
|
|
||||||
const manager = withLocalSession();
|
|
||||||
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
|
|
||||||
await manager.refreshPaneExits(NOW);
|
|
||||||
manager.rows = [];
|
|
||||||
await manager.refreshPaneExits(NOW + 2000);
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-s1')).toBe(1);
|
|
||||||
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
|
|
||||||
await manager.refreshPaneExits(NOW + 4000);
|
|
||||||
expect(manager.getPaneExitReadCount('codeman-s1')).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('discards a read that started before the pane was cleared', async () => {
|
it('discards a read that started before the pane was cleared', async () => {
|
||||||
// The guard that stops an in-flight read from republishing a death over
|
// The guard that stops an in-flight read from republishing a death over
|
||||||
// the pane that has just replaced it.
|
// the pane that has just replaced it.
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
// test/url-session-fragment.test.ts
|
|
||||||
// Port: N/A (no server/browser — loads constants.js and app.js via `vm`, like session-select-ack-gate.test.ts).
|
|
||||||
//
|
|
||||||
// A page that holds the dashboard's window switches its tab with a
|
|
||||||
// `#session=<id>` link, and sessionIdFromFragment() is what reads the link.
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
function loadHelper() {
|
|
||||||
const context = vm.createContext({ window: {}, globalThis: {}, URLSearchParams });
|
|
||||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
|
||||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
|
||||||
return (context.window as { CodemanUrlSession: { sessionIdFromFragment: (hash: unknown) => string | null } })
|
|
||||||
.CodemanUrlSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CodemanUrlSession.sessionIdFromFragment', () => {
|
|
||||||
const { sessionIdFromFragment } = loadHelper();
|
|
||||||
|
|
||||||
it('reads the id from a #session= fragment', () => {
|
|
||||||
expect(sessionIdFromFragment('#session=76763752-fa3a-40aa-a025-e1684c82d00e')).toBe(
|
|
||||||
'76763752-fa3a-40aa-a025-e1684c82d00e'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts the fragment without its leading #', () => {
|
|
||||||
expect(sessionIdFromFragment('session=abc')).toBe('abc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decodes an encoded id', () => {
|
|
||||||
expect(sessionIdFromFragment('#session=' + encodeURIComponent('w1 my/app'))).toBe('w1 my/app');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds the id beside other fragment parameters', () => {
|
|
||||||
expect(sessionIdFromFragment('#tab=2&session=abc')).toBe('abc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('asks for nothing when the fragment names no session', () => {
|
|
||||||
expect(sessionIdFromFragment('')).toBeNull();
|
|
||||||
expect(sessionIdFromFragment('#')).toBeNull();
|
|
||||||
expect(sessionIdFromFragment('#settings')).toBeNull();
|
|
||||||
expect(sessionIdFromFragment('#session=')).toBeNull();
|
|
||||||
expect(sessionIdFromFragment('#session=%20')).toBeNull();
|
|
||||||
expect(sessionIdFromFragment(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The dashboard side: reading the link, holding an id it does not list yet,
|
|
||||||
// and handing the selection over. Loaded like session-select-ack-gate.test.ts,
|
|
||||||
// on a bare instance whose DOM-touching methods are stubbed. webview-tabs.js
|
|
||||||
// rides along because opening a web tab is one of the ways a waiting link ends.
|
|
||||||
function loadApp() {
|
|
||||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
|
||||||
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
||||||
const webviewTabs = readFileSync(resolve(import.meta.dirname, '../src/web/public/webview-tabs.js'), 'utf8');
|
|
||||||
const location = { hash: '', pathname: '/', search: '' };
|
|
||||||
const history = {
|
|
||||||
state: null,
|
|
||||||
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
|
|
||||||
location.hash = url.includes('#') ? url.slice(url.indexOf('#')) : '';
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const context = vm.createContext({
|
|
||||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
||||||
performance,
|
|
||||||
setInterval: vi.fn(),
|
|
||||||
clearInterval: vi.fn(),
|
|
||||||
setTimeout,
|
|
||||||
clearTimeout,
|
|
||||||
requestAnimationFrame: vi.fn(),
|
|
||||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
|
||||||
WebSocket: { OPEN: 1 },
|
|
||||||
fetch: vi.fn(),
|
|
||||||
URLSearchParams,
|
|
||||||
location,
|
|
||||||
history,
|
|
||||||
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
|
||||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
|
||||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
||||||
MobileDetection: { isTouchDevice: () => false },
|
|
||||||
});
|
|
||||||
vm.runInContext(
|
|
||||||
`${constants}\n${app}\n${webviewTabs}\nglobalThis.__CodemanApp = CodemanApp;\nglobalThis.__waitMs = URL_SESSION_WAIT_MS;`,
|
|
||||||
context
|
|
||||||
);
|
|
||||||
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
|
||||||
const waitMs = (context as { __waitMs: number }).__waitMs;
|
|
||||||
const make = (ids: string[]) => {
|
|
||||||
const inst = Object.create(CodemanApp.prototype) as Record<string, any>;
|
|
||||||
inst.sessions = new Map(ids.map((id) => [id, { id, name: id }]));
|
|
||||||
inst.sessionOrder = [...ids];
|
|
||||||
inst.detachedSessions = new Set();
|
|
||||||
inst.detachedWindows = new Map();
|
|
||||||
inst.isSoloWindow = false;
|
|
||||||
inst._urlSessionId = null;
|
|
||||||
inst._urlSessionWaitTimer = null;
|
|
||||||
inst.selectSession = vi.fn();
|
|
||||||
inst.showToast = vi.fn();
|
|
||||||
for (const stub of [
|
|
||||||
'saveSessionOrder',
|
|
||||||
'markSessionTabEntering',
|
|
||||||
'markTerminalEntering',
|
|
||||||
'renderSessionTabs',
|
|
||||||
'updateCost',
|
|
||||||
'startSystemStatsPolling',
|
|
||||||
]) {
|
|
||||||
inst[stub] = vi.fn();
|
|
||||||
}
|
|
||||||
return inst;
|
|
||||||
};
|
|
||||||
return { make, location, history, CodemanApp, waitMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('dashboard handling of a #session=<id> link', () => {
|
|
||||||
it('reads the link and removes the fragment, so the same link counts as a change next time', () => {
|
|
||||||
const { make, location, history } = loadApp();
|
|
||||||
const app = make(['a']);
|
|
||||||
location.hash = '#session=a';
|
|
||||||
expect(app._takeUrlSession()).toBe('a');
|
|
||||||
expect(history.replaceState).toHaveBeenCalledWith(null, '', '/');
|
|
||||||
expect(location.hash).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a URL without a session link alone', () => {
|
|
||||||
const { make, location, history } = loadApp();
|
|
||||||
location.hash = '#settings';
|
|
||||||
expect(make([])._takeUrlSession()).toBeNull();
|
|
||||||
expect(history.replaceState).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selects a listed session as an app selection, which leaves its idle alert armed', () => {
|
|
||||||
const { make } = loadApp();
|
|
||||||
const app = make(['a']);
|
|
||||||
app._urlSessionId = 'a';
|
|
||||||
expect(app._selectUrlSession()).toBe(true);
|
|
||||||
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true });
|
|
||||||
expect(app._urlSessionId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds an unlisted id until session:created names it', () => {
|
|
||||||
const { make } = loadApp();
|
|
||||||
const app = make([]);
|
|
||||||
app._urlSessionId = 'new';
|
|
||||||
expect(app._selectUrlSession()).toBe(false);
|
|
||||||
expect(app.selectSession).not.toHaveBeenCalled();
|
|
||||||
app._onSessionCreated({ id: 'other', name: 'other' });
|
|
||||||
expect(app.selectSession).not.toHaveBeenCalled();
|
|
||||||
app._onSessionCreated({ id: 'new', name: 'new' });
|
|
||||||
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
|
|
||||||
expect(app._urlSessionId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retires a waiting link when you pick another tab yourself', async () => {
|
|
||||||
const { make, CodemanApp } = loadApp();
|
|
||||||
const app = make(['a', 'b']);
|
|
||||||
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
|
|
||||||
app._urlSessionId = 'later';
|
|
||||||
await app.selectSession('b').catch(() => {});
|
|
||||||
expect(app._urlSessionId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a waiting link through a selection the app makes itself', async () => {
|
|
||||||
const { make, CodemanApp } = loadApp();
|
|
||||||
const app = make(['a', 'b']);
|
|
||||||
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
|
|
||||||
app._urlSessionId = 'later';
|
|
||||||
await app.selectSession('b', { auto: true }).catch(() => {});
|
|
||||||
expect(app._urlSessionId).toBe('later');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts the wait for an unlisted link once the page has loaded its session list', () => {
|
|
||||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
||||||
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
|
|
||||||
const wait = source.indexOf('if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);');
|
|
||||||
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
|
|
||||||
expect(wait).toBeGreaterThan(link);
|
|
||||||
expect(wait).toBeLessThan(restore);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('puts the link ahead of restoring the last active tab when the page loads', () => {
|
|
||||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
||||||
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
|
|
||||||
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
|
|
||||||
expect(link).toBeGreaterThan(-1);
|
|
||||||
expect(link).toBeLessThan(restore);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never reads the link in a solo window', () => {
|
|
||||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
||||||
expect(source).toContain('this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();');
|
|
||||||
expect(source).toMatch(/if \(!this\.isSoloWindow\) \{\s*window\.addEventListener\('hashchange'/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A link whose session never turns up: a stale link (the session is closed), a
|
|
||||||
// typo, or in multi-user mode another user's session, which is never in this
|
|
||||||
// client's list. It must not wait forever with nothing on screen, and choosing
|
|
||||||
// something else must end it, or a session turning up later takes the screen.
|
|
||||||
describe('a #session=<id> link that is still waiting', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('waits 30 seconds', () => {
|
|
||||||
expect(loadApp().waitMs).toBe(30_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is dropped with a toast when its session has not appeared in time', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { make, waitMs } = loadApp();
|
|
||||||
const app = make([]);
|
|
||||||
app._urlSessionId = 'gone';
|
|
||||||
expect(app._selectUrlSession()).toBe(false);
|
|
||||||
vi.advanceTimersByTime(waitMs - 1);
|
|
||||||
expect(app._urlSessionId).toBe('gone');
|
|
||||||
expect(app.showToast).not.toHaveBeenCalled();
|
|
||||||
vi.advanceTimersByTime(1);
|
|
||||||
expect(app._urlSessionId).toBeNull();
|
|
||||||
expect(app._urlSessionWaitTimer).toBeNull();
|
|
||||||
expect(app.showToast).toHaveBeenCalledWith('Session not found', 'warning');
|
|
||||||
// Retired for good: the session turning up afterwards does not take the tab.
|
|
||||||
app._onSessionCreated({ id: 'gone', name: 'gone' });
|
|
||||||
expect(app.selectSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still selects a session that arrives before the wait runs out, and stops the timer', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { make, waitMs } = loadApp();
|
|
||||||
const app = make([]);
|
|
||||||
app._urlSessionId = 'new';
|
|
||||||
app._selectUrlSession();
|
|
||||||
vi.advanceTimersByTime(waitMs - 1);
|
|
||||||
app._onSessionCreated({ id: 'new', name: 'new' });
|
|
||||||
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
|
|
||||||
expect(app._urlSessionWaitTimer).toBeNull();
|
|
||||||
vi.advanceTimersByTime(waitMs);
|
|
||||||
expect(app.showToast).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not restart its wait when handleInit asks again', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { make, waitMs } = loadApp();
|
|
||||||
const app = make([]);
|
|
||||||
app._urlSessionId = 'gone';
|
|
||||||
app._armUrlSessionWait('gone');
|
|
||||||
vi.advanceTimersByTime(waitMs - 1000);
|
|
||||||
app._armUrlSessionWait('gone');
|
|
||||||
vi.advanceTimersByTime(1000);
|
|
||||||
expect(app._urlSessionId).toBeNull();
|
|
||||||
expect(app.showToast).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is retired by going Home', () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { make, waitMs } = loadApp();
|
|
||||||
const app = make([]);
|
|
||||||
app.terminal = { clear: vi.fn() };
|
|
||||||
app.showWelcome = vi.fn();
|
|
||||||
app.renderRalphStatePanel = vi.fn();
|
|
||||||
app._urlSessionId = 'later';
|
|
||||||
app._selectUrlSession();
|
|
||||||
app.goHome();
|
|
||||||
expect(app._urlSessionId).toBeNull();
|
|
||||||
expect(app._urlSessionWaitTimer).toBeNull();
|
|
||||||
app._onSessionCreated({ id: 'later', name: 'later' });
|
|
||||||
expect(app.selectSession).not.toHaveBeenCalled();
|
|
||||||
vi.advanceTimersByTime(waitMs);
|
|
||||||
expect(app.showToast).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
function withWebTab(app: Record<string, any>) {
|
|
||||||
const webview = { id: 'dash', name: 'Dash', url: 'http://127.0.0.1:8080/' };
|
|
||||||
app.webviews = new Map([['dash', webview]]);
|
|
||||||
app.webviewOrder = ['dash'];
|
|
||||||
app._persistWebviewOrder = vi.fn();
|
|
||||||
app._apiJson = vi.fn(async () => ({ webview, embedUrl: '/webview/cap/' }));
|
|
||||||
app._mountWebviewFrame = vi.fn();
|
|
||||||
app.hideWelcome = vi.fn();
|
|
||||||
app._updateActiveWebviewTab = vi.fn();
|
|
||||||
app.closeSessionSidebarOnHandheld = vi.fn();
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('is retired by opening a web tab', async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { make, waitMs } = loadApp();
|
|
||||||
const app = withWebTab(make([]));
|
|
||||||
app._urlSessionId = 'later';
|
|
||||||
app._selectUrlSession();
|
|
||||||
const opening = app.openWebview('dash');
|
|
||||||
// Before the open's await: a session:created landing inside it finds no link.
|
|
||||||
expect(app._urlSessionId).toBeNull();
|
|
||||||
expect(app._urlSessionWaitTimer).toBeNull();
|
|
||||||
await opening;
|
|
||||||
expect(app.activeWebviewId).toBe('dash');
|
|
||||||
app._onSessionCreated({ id: 'later', name: 'later' });
|
|
||||||
expect(app.selectSession).not.toHaveBeenCalled();
|
|
||||||
vi.advanceTimersByTime(waitMs);
|
|
||||||
expect(app.showToast).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('survives a web tab the app opens itself', async () => {
|
|
||||||
const { make } = loadApp();
|
|
||||||
const app = withWebTab(make([]));
|
|
||||||
app._urlSessionId = 'later';
|
|
||||||
await app.openWebview('dash', { auto: true });
|
|
||||||
expect(app._urlSessionId).toBe('later');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user