Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28cbbad591 | ||
|
|
64fea4ff84 | ||
|
|
0ce6e09725 | ||
|
|
3952963bad | ||
|
|
8cffcf4be5 | ||
|
|
517e12fe1d | ||
|
|
a8a8bb71a3 | ||
|
|
1a692f588e | ||
|
|
b68b098d1c | ||
|
|
03e6a159b1 | ||
|
|
29f76e7ec3 | ||
|
|
bc2d42c2c8 | ||
|
|
af8d0ef69c | ||
|
|
7eaa39a67e | ||
|
|
9faf567d41 | ||
|
|
91750d4bd5 | ||
|
|
63ac10dcc0 | ||
|
|
48e91fc1d9 | ||
|
|
3975089af3 | ||
|
|
9ad43fbd88 | ||
|
|
5e83cd3885 | ||
|
|
1172ef56f8 | ||
|
|
0996f99fbb | ||
|
|
b15033259a | ||
|
|
0feb5658ca | ||
|
|
82d588a339 | ||
|
|
1f41eacd87 | ||
|
|
a8b8530722 | ||
|
|
c3cee40f56 | ||
|
|
b7c18ab5b7 | ||
|
|
70e03b8ae7 | ||
|
|
de06cb12d1 | ||
|
|
eaa9044520 | ||
|
|
6c7d63d45e | ||
|
|
6bbfc44054 | ||
|
|
3d341f46a4 | ||
|
|
f0b762e745 | ||
|
|
771c23e261 | ||
|
|
0ff9b798af | ||
|
|
33c6cf5d20 | ||
|
|
9b9752d997 | ||
|
|
60fd71470c | ||
|
|
836402c2c4 | ||
|
|
f073742cf7 | ||
|
|
f3d7a86c16 | ||
|
|
34b867025d | ||
|
|
5d31e42db0 | ||
|
|
a6ce52d3e7 | ||
|
|
5ac27f3eec | ||
|
|
a38eab49cd | ||
|
|
9c56f7823d | ||
|
|
076f62fa1b | ||
|
|
90034d07d7 | ||
|
|
887ee7fe64 | ||
|
|
62227145dc | ||
|
|
2e65daff83 | ||
|
|
0e11012470 | ||
|
|
5b8313988f | ||
|
|
628b6e5832 | ||
|
|
aca358126d | ||
|
|
239f15437b | ||
|
|
f78b820302 | ||
|
|
d571c41fb1 | ||
|
|
f9f00bdb5e | ||
|
|
78ed6bf004 | ||
|
|
43568775e6 | ||
|
|
36f8afdaba | ||
|
|
1855d9423b | ||
|
|
7ba1e51c51 | ||
|
|
b4c87360d3 | ||
|
|
ee038ad83c | ||
|
|
f2d5af2b4c | ||
|
|
dedf56e24b | ||
|
|
71647e090d | ||
|
|
35e21dd676 | ||
|
|
3072449c94 | ||
|
|
bade1f1966 | ||
|
|
5205649889 | ||
|
|
e65b4f35e6 | ||
|
|
50a1b58d2b | ||
|
|
9e8a851a2c | ||
|
|
03a9259ae1 | ||
|
|
ba5649bcd4 | ||
|
|
c1fde8e639 | ||
|
|
1c19641280 | ||
|
|
79b9181932 | ||
|
|
0168444fda | ||
|
|
5a13073657 | ||
|
|
8f62faf130 | ||
|
|
7b4832b3e4 | ||
|
|
7dd78d9d71 | ||
|
|
06f1e5ddc8 | ||
|
|
4544379b3b | ||
|
|
fa23f8be2a | ||
|
|
bc940a3953 | ||
|
|
432faf7254 | ||
|
|
69aa5724e7 | ||
|
|
876dbca074 | ||
|
|
fe50ad0be3 | ||
|
|
ded7911d1e | ||
|
|
2797098aa5 | ||
|
|
4ecf7fa228 | ||
|
|
c041a418e0 | ||
|
|
59b92f2991 | ||
|
|
683a1c218f | ||
|
|
435a56be10 | ||
|
|
5b8c73827e | ||
|
|
3c08971ff8 | ||
|
|
6bfc1b88d8 | ||
|
|
6bffc0481c | ||
|
|
f5e59283f9 | ||
|
|
8d92e577f8 | ||
|
|
ecb2e3d8f3 | ||
|
|
8e2594a274 | ||
|
|
3857fba065 | ||
|
|
28b41efa58 | ||
|
|
063e6fc076 | ||
|
|
136f11549b | ||
|
|
dd7774f9ad | ||
|
|
d6c87bbd27 | ||
|
|
3ea1e03aaf | ||
|
|
afab34608a | ||
|
|
eaf924a291 | ||
|
|
da7ffec734 | ||
|
|
1010d80b4f | ||
|
|
7d46a9d336 | ||
|
|
3e75e7509f | ||
|
|
b620e42ffa | ||
|
|
b33d6da58a | ||
|
|
d157911166 | ||
|
|
cde90a465b | ||
|
|
34c903da41 | ||
|
|
551a7f2792 | ||
|
|
331841f482 | ||
|
|
0fced6ea97 | ||
|
|
79fd2dc8e4 | ||
|
|
0ae679553b | ||
|
|
2c55fe907e | ||
|
|
df1a493b8c | ||
|
|
e2453fe97b | ||
|
|
5fb56b0889 | ||
|
|
d7198dd058 | ||
|
|
a4926e49f1 | ||
|
|
2eff6dcbdd | ||
|
|
b8ccfc33d1 | ||
|
|
91bdaee724 | ||
|
|
56978f2cc9 | ||
|
|
fe77b5238e |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"name": "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.",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -84,6 +84,23 @@ tmp/
|
||||
# added there). No trailing slash so it still matches the symlink, not just dirs.
|
||||
/public
|
||||
|
||||
# Video never goes to GitHub (docs/media-assets.md). The only tracked moving
|
||||
# images are the GIFs a public page embeds, in docs/images/; masters, promo cuts,
|
||||
# drafts and captures live in the local media library pr/media/ (ignored above).
|
||||
# The promo branch tracks its Remotion render inputs, so those stay addable.
|
||||
# A deliberate exception elsewhere takes `git add -f`.
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mov
|
||||
*.m4v
|
||||
*.mkv
|
||||
*.avi
|
||||
*.gif
|
||||
!/docs/images/*.gif
|
||||
!/scripts/remotion/public/**/*.mp4
|
||||
!/scripts/remotion/public/**/*.webm
|
||||
!/scripts/remotion/public/**/*.gif
|
||||
|
||||
# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB)
|
||||
# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git.
|
||||
# (The gesture bundle itself, gesture-codeman.js, IS tracked — built from
|
||||
|
||||
@@ -28,3 +28,5 @@ scripts/remotion/
|
||||
|
||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||
CLAUDE.md
|
||||
src/web/CLAUDE.md
|
||||
docs/releasing.md
|
||||
@@ -1,5 +1,56 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.41.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ### Thanks
|
||||
- @timkjr for four PRs: shells that reload from tmux's rendered history instead of replaying old full-screen repaints (#601), the full scrollback of a remote SSH session read from the remote tmux (#602) with no rows lost at the join (#609), and a phone shell Paste that inserts a multi-line block instead of running it line by line (#600).
|
||||
- @aakhter for three PRs: Codex panes that page their transcript with the wheel and touch (#527, fixes #508), new and spawned tabs that land in tab groups (#528), and a Focus section of shortcuts at the top of the vertical rail (#582).
|
||||
- @JDProfresh for three PRs: Claude Code's prompt-cache state per session (#607, step 1 of #554), the phone Attach button with camera-roll video uploads (#606, part two of #553), and an upload that no longer stalls the server on a dead network mount (#605).
|
||||
- @Randalix for clickable `~/`, relative and hard-wrapped file paths plus a folder grid for browsing a whole folder of renders (#604, closes #603).
|
||||
- @opticon454 for three PRs: a Working folder row with a Copy button in Session Options (#593), tab colour, pin and image watcher surviving a restart (#591), and Link Existing saying why it refused a case (#590).
|
||||
- @ideaCompany, a first-time contributor, for rendering local HTML files as real pages in the File Viewer (#547), and for the careful security write-up behind it.
|
||||
- @zomby1, a first-time contributor, for OSC 52 clipboard support (#611).
|
||||
- @Hugo-DDT, a first-time contributor, for two PRs: MCP server sync reading Gemini's bare `url`, OpenCode's V2 layout and `opencode.jsonc` (#599, fixes #586), and DeepSeek permission prompts that no longer offer Approve/Deny buttons that cannot work (#610, from #598).
|
||||
|
||||
**HTML files open as pages, and markdown opens rendered from the terminal (#547).** Click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer now shows it as a real page, with its own scripts, styles, images and data files from its folder, instead of raw source. A **Page** pill switches to the source and its Edit button. The page runs in a sandbox with an opaque origin: it cannot read Codeman's cookies, use Codeman's API or read files outside its own folder. Network access is open, so a dashboard that loads Chart.js or fonts from a CDN works as it would opened from disk. Broad folders (`/`, home, `/tmp`, a drive's mount point) are refused, so put a report in a folder of its own. And a `.md` or `.markdown` path clicked in the terminal now opens the rendered markdown viewer everywhere (main terminal, tiles, split pane, touch), instead of the live log viewer that showed "streaming"; the MD pill still gives the source. Logs, `.txt`, `.json` and code keep the live log viewer.
|
||||
|
||||
**`codeman mcp`: drive Codeman from any MCP client (#615).** A stdio Model Context Protocol server with seven tools (list, start, send, wait, read, interrupt, delete), so Claude Code, Codex, Gemini CLI, Cursor or Claude Desktop can start worker sessions in any installed agent CLI, prompt them, wait for them and read what they did. It is a client of the existing HTTP API with the same guards as `codeman agent`: it refuses control characters in input, never acts on the session it runs in, and by default only touches sessions it started. Setup lines for Claude Code, JSON clients and Codex are in the README and the wiki page "Driving Codeman From An Agent".
|
||||
|
||||
**The codeman skill for every agent CLI (#613).** With **Codeman Agent Skill** on (App Settings > Agents & CLIs > All agent CLIs, still off by default), Codex, Gemini CLI, OpenCode, Antigravity, Grok Build, DeepSeek Harness and OMP sessions now get the same `codeman` skill Claude Code had, in `.agents/skills/codeman`, so an agent in any mode knows how to start and manage worker sessions. `codeman skill install --mode <cli>` installs it by hand.
|
||||
|
||||
**Tiles: move them by hand, or tear one off into its own window (#584).** With the camera gesture overlay on, pinch a tile and carry it onto another tile to swap them, onto an empty cell to move it, or carry a tab onto the grid to add it. With the new **Detach Tiles** setting (App Settings > Appearance, off by default), drag a tile's header out of the window and it pops out into its own window; drop it on another Codeman window's tiles and it moves there. Known limits: all Codeman windows in one browser share one saved tile layout, a drag between two browser profiles is not seen by the source window, and a drag-out held for more than a few seconds can be blocked as a pop-up.
|
||||
|
||||
**Focus section on the vertical rail (#582).** "Add to Focus" in a row's menu (or right-click a web tab) pins a shortcut to that tab in a small collapsible section at the top of the rail. The tab itself stays where it is.
|
||||
|
||||
**Tabs land in groups (#528).** A tab group's menu has **New session**, which starts a session straight into that group. A session an agent spawns lands right after its parent; App Settings > Appearance > Tabs > **Spawned tabs follow their parent** (off by default) keeps it moving with the parent.
|
||||
|
||||
**Folder grid and more clickable paths (#604).** Paths written as `~/...`, relative to the session folder, or broken across terminal lines are now clickable in the terminal and the Response Viewer, and folders are too: a folder opens a grid of its files (renders, screenshots, reports) that you can page through with the arrow keys.
|
||||
|
||||
**Phone Attach button and videos (#606).** On a phone the toolbar has an Attach button that opens the system file sheet directly, without raising the keyboard, and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into `.codeman-uploads/` like images.
|
||||
|
||||
**OSC 52 clipboard (#611).** A program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) now puts the text on your browser's clipboard, in the main terminal and in every tile. Read requests are refused, and a replayed buffer never copies.
|
||||
|
||||
**Prompt-cache readout (#607).** Claude sessions show whether their prompt cache is warm (`cache:until HH:MM`) or cold in the footer and in Session Options > Respawn, restored after a restart, with a reason when a launch will never report it. Display only, nothing changes behaviour.
|
||||
|
||||
**Faster tab switches to fullscreen Claude (#588, #583).** Switching back to a fullscreen Claude tab now loads only its current screen (a few KB) instead of replaying a 1 MB tail of old redraws (about 1.3 to 1.7 s saved per switch). The "Showing the most recent 1.0 MB" bar now appears only when there really is older history and you scroll to the top looking for it, gives a line count, and stays closed once you close it.
|
||||
|
||||
**Fixes.**
|
||||
- **Shells reload cleanly (#601).** A shell tab, tile or split pane reloads from tmux's rendered history, so a prompt no longer appears four or five times and lines no longer go missing after a resize or a full-screen program.
|
||||
- **Remote SSH sessions keep their scrollback (#602, #609).** Loading a remote session's full history reads it from the remote tmux in one bounded ssh round trip, where the local pane kept only a fraction (48 of 279 lines in one measurement, none after a reattach).
|
||||
- **Codex pages with the wheel and touch (#527).** A Codex pane on its alternate screen pages its transcript with PageUp/PageDown from the first wheel notch or swipe, in the main terminal and in tiles, and never pages into an open permission dialog.
|
||||
- **openpyxl spreadsheets preview (#612).** An `.xlsx` written by openpyxl with a cell comment, a table or a chart now opens in the File Viewer instead of failing.
|
||||
- **Phone shell Paste (#600).** A multi-line paste in a shell is inserted as one bracketed paste and waits for Enter, instead of running each line as it arrives.
|
||||
- **Restart keeps tab colour, pin and image watcher (#591).**
|
||||
- **Session Options shows the working folder (#593)**, with a Copy button and a label that says when the folder is on a remote host or mounted into a Docker container.
|
||||
- **Link Existing says why (#590)**, for example "Invalid case name format", instead of "Invalid request body".
|
||||
- **Uploads on a dead mount (#605).** A prompt upload into a workspace on a network mount that stopped answering is refused at once instead of tying up a server thread.
|
||||
- **MCP server sync (#599).** Gemini servers with a bare `url` sync as HTTP, OpenCode's V2 `mcp.servers` layout and `opencode.jsonc` are read.
|
||||
- **DeepSeek approvals (#610).** DeepSeek Harness permission prompts show "Answer in the terminal" in the Approvals Inbox, the phone NEEDS YOU strip and push notifications, instead of Approve/Deny buttons the server always refused.
|
||||
|
||||
**Fixes applied while landing.** Remote SSH and Docker tabs keep their scrollback on a tab switch (they were about to lose it with #588). No colour bleed where remote history meets the live screen (#602, #609). A bare `~` in a Docker case no longer lists the host's home folder, and shell prompts (`user@host:~/proj$`) no longer turn into broken folder links (#604). Spawned sibling tabs keep their creation order (#528). An OSC 52 copy over plain HTTP gives keyboard focus back to the terminal (#611). A refused video upload answers instead of hanging, and the zh-CN "Attach" label of the tile grid is back (#606). The Working folder row fits on phones, with zh-CN text (#593). OpenCode sync no longer stalls on a server named `servers` (#599). Moving a tile to another window no longer overwrites that window's saved layout (#584). HTML pages: a `~/` link to an `.html` file renders as a page instead of falling back to source, `/tmp` is refused even when `TMPDIR` points elsewhere, symlinks cannot get around the hidden-file and file-type rules, workspace confinement is re-checked on every request, media in a page can seek, and the service worker never caches a page (#547). Codex users of `codeman mcp`: the config now forwards the session id, so its self-protection works (#615).
|
||||
|
||||
## 1.41.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -50,35 +50,9 @@ The production server caches static files for 1 year, `immutable` (`maxAge: '1y'
|
||||
|
||||
Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`. What SemVer actually covers (the CLI, documented env vars, **and the HTTP/SSE API under `/api/v1`**: endpoint paths, response envelope, `errorCode` values and SSE event names are public/stable; on-disk state, internal TS modules, and experimental features are internal/unstable) is defined in `docs/versioning-policy.md`. Third-party integration surfaces are documented in `docs/extending-codeman.md`. Security reporting + known limitations live in `.github/SECURITY.md`.
|
||||
|
||||
When user says "COM":
|
||||
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
|
||||
|
||||
1. **Determine bump type**: `COM` = patch (default), `COM minor` = minor, `COM major` = major
|
||||
2. **Create a changeset file** (no interactive prompts). Write a `.md` file in `.changeset/` with a random filename:
|
||||
|
||||
```bash
|
||||
cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET'
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
|
||||
CHANGESET
|
||||
```
|
||||
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
|
||||
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
8. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
|
||||
|
||||
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.41.0 (must match `package.json`)
|
||||
**Version**: 1.41.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -98,30 +72,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
| Task | Command |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Terminal dashboard | `codeman tui` (`--list` prints the numbered list and exits, `codeman tui <n>` attaches to row n; both short-circuit before any screen setup). Needs a TTY; without a server it starts attach-only. `docs/tui.md` |
|
||||
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
||||
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
|
||||
| Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` |
|
||||
| Mount under a reverse-proxy sub-path | `npx tsx src/index.ts web --base-url /codeman` (env `CODEMAN_BASE_URL`; default `/`). Normalized in `src/config/base-path.ts` (`''` = root). See Reverse-proxy base path below + `docs/wiki/Remote-Access.md` |
|
||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||
| Watch-mode test | `npm run test:watch -- test/<file>.test.ts` (runs the CI gate's config; pass a file to narrow it) |
|
||||
| Test coverage | `npm run test:coverage` |
|
||||
| Dead-code sweep | `npm run knip` (config in `config/knip.json`, passed via `--config`) |
|
||||
| Rebuild gesture overlay | `npm run build:gesture` (esbuild `packages/gesture-control/src/codeman/entry.ts` → `src/web/public/gesture/gesture-codeman.js`; commit the result) |
|
||||
| Regenerate the CLI catalogue | `npm run generate:cli-catalog` (`--check` to fail on drift). Rewrites `config/clis.stock.json` **and** the marked block in `install.sh` from `stock.ts`. ⚠ **Commit both.** They are what `install.sh` and the Docker agent image read, since neither can import TypeScript; `test/cli-catalog-sync.test.ts` and a CI `--check` step fail if either goes stale. See `docs/cli-registry.md` |
|
||||
| Build the docker agent image | `node scripts/build-agent-image.mjs --no-cache` (builds `codeman/agent:base` from `docker/agent.Dockerfile`; prerequisite for Docker cases; `--engine`/`--image`). ⚠ **Always `--no-cache`** — a plain rebuild re-uses the cached `npm install -g` layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See `docs/docker-cases.md` |
|
||||
| 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`) |
|
||||
| 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 |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
|
||||
| Install/remove the service | `codeman service install` / `status` / `uninstall` (systemd user unit on Linux, LaunchAgent on macOS; names from `config/service-names.ts`) |
|
||||
| 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) |
|
||||
|
||||
**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 the Playwright tests in `BROWSER_TEST_GLOBS`; 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/`). `browser-suite.yml` runs `npm run test:browser` (minus codex-predictive-echo) nightly and on demand via `workflow_dispatch`; it is informational and never gates a push or PR.
|
||||
|
||||
@@ -155,29 +112,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Core Files (by domain)
|
||||
|
||||
| Domain | Key files | Notes |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts`, `daemon-control`, `service-installer`, `config/service-names`, `cli-style` | The last three back `web -d` / `service install`; `cli-style` is the shared palette/table/spinner/confirm kit |
|
||||
| **TUI** | `src/tui/`: `tui-app` ★ + `tui-client` (the only IO) over a pure core (`-model`, `-layout`, `-render`, `-keys`, `-ansi`, `-composer`, `-approvals`, `-digest`, `-sse`, `-types`) | `codeman tui`, a CLIENT of the server, never a second brain. Design doc: `docs/tui-plan.md`; user guide `docs/tui.md` |
|
||||
| **DeepSeek** | `src/utils/deepseek-cli-resolver.ts`, `src/deepseek-status-shim.ts`, `src/deepseek-web-server.ts` (background `dsh web`, not a session) | `dsh` is a PROFILE LAUNCHER, not an agent; read `docs/deepseek-integration.md` first |
|
||||
| **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `session-trust-dialog` (pure), `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★, `src/proc-tree.ts` (pure, bounded descendant walk) | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
|
||||
| **Orchestrator** | `src/orchestrator-loop.ts`, `-planner`, `-verifier` | Read `docs/orchestrator-loop-architecture.md` first |
|
||||
| **Cron** | `src/cron/cron-service.ts`, `cron-time.ts` (pure next-run math), `cron-input.ts` | Read `docs/cron-discovery.md` first. Distinct from legacy `ScheduledRun` (`/api/scheduled`) |
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `team-watcher`, `bash-tool-parser`, `transcript-watcher`, `workflow-run-watcher` | `workflow-run-watcher` is STANDALONE and never touches `subagent-watcher` |
|
||||
| **AI** | `src/ai-checker-base.ts`, `ai-idle-checker.ts`, `ai-plan-checker.ts` | |
|
||||
| **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts`, `intent-store.ts`, `tab-layout.ts` (pure model) + `-service` (sole mutation boundary) + `-persistence` + `-legacy-order` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` + `remote-wake` (IO: `dgram`/`net`/`child_process`), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns |
|
||||
| **Web tabs** | `src/webview-store.ts`, `webview-capabilities.ts`, `src/web/webview-proxy.ts` (pure), `src/web/routes/webview-routes.ts` | Dashboard URLs as tabs; NOT a SessionMode |
|
||||
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (one module per domain + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (core) + the modules listed in the Frontend load order + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
**Read before touching a subsystem**: respawn → `docs/respawn-state-machine.md`; Ralph → `docs/ralph-wiggum-guide.md`; orchestrator → `docs/orchestrator-loop-architecture.md`; cron → `docs/cron-discovery.md`; DeepSeek (`dsh` is a PROFILE LAUNCHER, not an agent) → `docs/deepseek-integration.md`; TUI (`src/tui/`, a CLIENT of the server, never a second brain; only `tui-app` + `tui-client` do IO over a pure core) → `docs/tui-plan.md`. Pure helpers are split out of `session.ts` and the other large files on purpose, so tests exercise the shipped code: keep new logic that way.
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
@@ -187,13 +122,6 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver` (CLI path resolution, one per `SessionMode`, all nine sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`).
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Session spawns `claude --dangerously-skip-permissions` via node-pty
|
||||
2. PTY output buffered, ANSI stripped, parsed for JSON messages
|
||||
3. WebServer broadcasts to SSE clients at `/api/events`
|
||||
4. State persists to `~/.codeman/state.json` via StateStore
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||
@@ -202,9 +130,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
|
||||
|
||||
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
|
||||
|
||||
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
|
||||
|
||||
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
||||
**Agent preamble cache GC**: the §0 preamble seeded per local session whose CLI takes the skill (`capabilities.agentSkillDir`) (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
@@ -224,7 +154,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
@@ -250,17 +180,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
@@ -276,17 +202,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)
|
||||
|
||||
**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 (`TerminalTile._pullHistory`, terminal-tile.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)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. SuchLine truncated
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](docs/architecture-invariants.md#auto-copy-copy-on-select)
|
||||
|
||||
**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)
|
||||
**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, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). 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 (`TerminalTile._pullHistory`, terminal-tile.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)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ 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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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)
|
||||
@@ -297,26 +213,26 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`; client wiring in `image-input.js` and `keyboard-accessory.js`): a pasted file is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode. A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--responseLine truncated
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
**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)
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
**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, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` 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)
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
@@ -331,76 +247,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
**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)
|
||||
|
||||
**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.
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
**Frontend invariants live in [`src/web/CLAUDE.md`](src/web/CLAUDE.md)**: module load-order notes and every `src/web/public/` UI subsystem (tab strip, sidebar, rail and tab layouts, tile grid, split pane, lineage lines, home screens, settings surface, skins and fonts, mobile keyboard/toolbar/composer, terminal touch, copy and paste, file-preview markdown, XLSX preview limits, z-index stack, connection-loss UI, SSE watchdog, keyboard shortcuts incl. the Ctrl+W owner decision). Claude Code loads it automatically when the Read tool opens a file under `src/web/`. ⚠️ Reading files through Bash (`cat`, `sed`, `grep`) does NOT load it, so **Read `src/web/CLAUDE.md` before editing anything in `src/web/public/`** if it is not already in context.
|
||||
|
||||
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
|
||||
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](docs/architecture-invariants.md#terminal-font-weight)
|
||||
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins)
|
||||
|
||||
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
|
||||
|
||||
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices)
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](docs/architecture-invariants.md#websocket-output-gap-reconcile)
|
||||
|
||||
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
|
||||
|
||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||
|
||||
### Security
|
||||
|
||||
**Full model: [`docs/security-architecture.md`](docs/security-architecture.md)** (network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, recommended setups). **Layer-by-layer detail with the history behind each: [architecture-invariants#security-layers](docs/architecture-invariants.md#security-layers).**
|
||||
@@ -450,6 +304,8 @@ All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, c
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
**Videos and GIFs**: only GIFs a public page embeds are tracked (`docs/images/`), and `.gitignore` blocks `*.mp4`/`*.webm`/`*.mov`/`*.gif` everywhere else. Masters, promo cuts, drafts and captures live in the local, gitignored media library `pr/media/` (guide `pr/media/README.md`, generated `INVENTORY.md` via `node pr/media/inventory.mjs`); never leave a render in `tmp/` or `out/`. A GIF leaving `docs/images/` needs a hotlink check first (articles embed raw `master` URLs). → `docs/media-assets.md`
|
||||
|
||||
## Testing
|
||||
|
||||
**`npm test` is the gate and is safe to run bare** — it runs `config/vitest.ci.config.ts`, exactly what CI runs, so local green means CI green.
|
||||
@@ -469,7 +325,7 @@ npm run test:perf # wall-clock benchmarks — need an otherwise idle machin
|
||||
npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default
|
||||
```
|
||||
|
||||
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above). The browser suite (not mobile or perf) also runs nightly in `.github/workflows/browser-suite.yml`, which catches a regression after it lands, not before.
|
||||
⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note in `src/web/CLAUDE.md`). The browser suite (not mobile or perf) also runs nightly in `.github/workflows/browser-suite.yml`, which catches a regression after it lands, not before.
|
||||
|
||||
⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||
</p>
|
||||
|
||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||
@@ -46,6 +46,7 @@ The installer asks before every system change, and re-running the same line upda
|
||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
||||
- **Agents can drive it too** - the [codeman skill](#the-agent-skill-start-here) (for every agent CLI), [`codeman agent`](#codeman-agent--session-to-session-verbs-in-every-cli-mode) and an [MCP server](#mcp-server-codeman-mcp-drive-codeman-from-any-mcp-client) let one agent start workers in any CLI, prompt them, wait for them and collect their answers
|
||||
- **Nothing gets lost** - tmux persistence across restarts and network drops, exactly-once input delivery, full-scrollback replay
|
||||
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
||||
|
||||
@@ -212,6 +213,8 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
|
||||
|
||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident; on Codex sessions the bar also shows `⇧←` / `⇧→` (Shift+Left / Shift+Right: edit the last queued message / return through the prompt stack)
|
||||
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
|
||||
- **Attach button**: the picture icon beside the mic opens the system photo and file sheet without raising the keyboard. Photos and camera-roll videos (mp4, mov, webm, up to 1 GB) upload into the session's folder and their path lands on your prompt, unsent
|
||||
- **Paste in a shell**: a multi-line paste goes in as one block and waits for your Enter, instead of running line by line as it arrives
|
||||
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
|
||||
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling; on a folding phone (iPhone Duo) dialogs stay clear of the hinge, and opening or closing the device is never mistaken for the keyboard
|
||||
|
||||
@@ -264,18 +267,18 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
### 3. Read the dashboard
|
||||
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet.
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices). Prefer a list? **App Settings → Appearance → Tabs** moves it into a left sidebar with a filter box (`Alt+B` collapses it) or a vertical rail whose rows sort by activity: blocked on you first, then longest running, then most recently quiet. The rail also has a **Search sessions** box, named **tab groups** (a group's menu starts a **New session** straight into it), and a **Focus** section of shortcuts to the tabs you keep coming back to.
|
||||
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
|
||||
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||
|
||||
### 4. Talk to the agent
|
||||
|
||||
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
|
||||
- **Paste or drag-and-drop images** directly into the session.
|
||||
- **Paste, drop or attach images and videos** directly into the session. They are saved in a hidden `.codeman-uploads/` folder in the workspace (it ignores itself in git and is swept after 7 days), and the file's path lands on your prompt, unsent.
|
||||
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop).
|
||||
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view.
|
||||
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view, including `~/` and relative paths and paths broken across lines. An `.html` report opens as a real page, Markdown opens rendered, and a folder opens as a grid of its files.
|
||||
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
|
||||
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it.
|
||||
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it. A program that copies with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard too.
|
||||
|
||||
### 5. Make it autonomous
|
||||
|
||||
@@ -294,7 +297,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
### 7. Operate & maintain
|
||||
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance animations, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, terminal font family and weight, entrance and tile animations (their own **Animations** section), notifications (with adjustable toast and browser-notification display times), display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language. **Apply** saves without closing Settings.
|
||||
- **Run it in the background** — `codeman web -d` detaches from your shell (`--status`, `--stop`); `codeman service install` makes it a systemd user unit / macOS LaunchAgent that survives reboots. Both verify the server actually answers before reporting success, and both refuse to start a second server on one data dir. See [Keep it running in the background](#quick-start---installation).
|
||||
- **Self-update** — git-clone installs update in place from **App Settings → System → Updates**.
|
||||
- **Deploy your own changes** — see [Development](#development).
|
||||
@@ -387,7 +390,14 @@ Run **20 parallel sessions** with full visibility — real-time xterm.js termina
|
||||
<img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800">
|
||||
</p>
|
||||
|
||||
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session; the grid is remembered for next time. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
|
||||
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
|
||||
|
||||
- **Opens what matters**: with no grid arranged yet, it fills the tiles with the sessions that are working first, then the ones waiting on you, then the most recently used, and always includes the one you are on.
|
||||
- **Keeps your layout**: once you arrange a grid (which session sits where, empty cells, tile count, divider sizes, the focused and zoomed tile), the Tiles button brings it back exactly, and so does a page reload while it was open.
|
||||
- **Tile animations** _(opt-in)_: **App Settings → Animations → Tile Animations** makes the grid open with a show: **CRT** (each tile switches on as a hot line, as in the GIF at the top), **Fly from tab**, **Deal**, **Beam down**, **Cascade**, **Pop** or **Soft**. Nothing moves under reduced motion.
|
||||
- **Scrolls like the main terminal**: the mouse wheel scrolls a fullscreen Claude conversation inside its tile, a Codex tile pages its transcript, and `Shift+wheel` scrolls the tile's local history.
|
||||
- **Detach Tiles** _(beta, opt-in)_: with **App Settings → Appearance → Detach Tiles** on, drag a tile's header out of the browser and it pops out into its own window; drop it on another Codeman window's tiles and it moves there.
|
||||
- **Hands-free**: with the [gesture overlay](#more-features) on, pinch a tile and carry it onto another tile to swap them, onto an empty cell to move it, or carry a tab onto the grid to add it.
|
||||
|
||||
### Persistent Sessions
|
||||
|
||||
@@ -464,10 +474,15 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Terminal & Input
|
||||
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Header & Panels → Header buttons
|
||||
- **HTML reports open as pages**: click an `.html` path an agent printed (a test report, a coverage page, a dashboard) and the File Viewer shows it as a real page, with the scripts, styles, images and data files from its folder; the **Page** pill switches to the source and its Edit button. The page runs sandboxed with an opaque origin, so it cannot read Codeman's cookies, call Codeman's API or read files outside its own folder. Network access stays open, so a dashboard that loads Chart.js or fonts from a CDN works as it would from disk. Broad folders (`/`, your home, `/tmp`) are refused, so give a report a folder of its own. A `.md` path clicked in the terminal opens rendered too
|
||||
- **Clickable paths and the folder grid**: paths written as `~/…`, relative to the session folder, or broken across terminal lines are clickable in the terminal and the Response Viewer, and so are folders: a folder opens a grid of its files (renders, screenshots, reports) that you page through with the arrow keys
|
||||
- **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies
|
||||
- **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
|
||||
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones
|
||||
- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order
|
||||
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in entrance animations for tabs, agent windows, the terminal pane and connection lines
|
||||
- **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart
|
||||
- **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device
|
||||
- **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in animations for tabs, agent windows, the terminal pane, connection lines and [tile-grid tiles](#tile-grid), all in App Settings → Animations (with a lab to try them)
|
||||
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
||||
|
||||
---
|
||||
@@ -499,6 +514,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
|
||||
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
|
||||
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
|
||||
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
|
||||
- **Full scrollback**: loading a remote session's history reads it from the remote tmux in one bounded ssh round trip, so a session keeps its whole scrollback after a reattach or a tab switch, with no rows lost where the history meets the live screen.
|
||||
|
||||
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
|
||||
|
||||
@@ -669,7 +685,7 @@ These run for **every** request — before auth, even on the default no-password
|
||||
### Input, files & headers
|
||||
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes. The one place HTML renders is the File Viewer's page view: a separate `/html-view/<capability>/` address scoped to the file's own folder, served under `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page has an opaque origin and can never read Codeman's cookies or drive its API; hidden folders and broad roots (`/`, home, `/tmp`) are refused, and the capabilities are revoked on logout
|
||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||
|
||||
### Supply chain & isolation
|
||||
@@ -703,7 +719,7 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | ------------------------------------------------------------- |
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl+W` | Not a Codeman shortcut: it reaches the terminal (delete the previous word). **Close Session** has no default key and can be bound in App Settings → Shortcuts |
|
||||
| `Ctrl/Cmd/Option+K` | Find open session or start a new one |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
@@ -716,6 +732,10 @@ The web UI remains the primary surface; see **[docs/tui.md](docs/tui.md)** for t
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl+Shift+G` | Open or close the tile grid |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile one place (into an empty cell, or swap) |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid |
|
||||
| `Ctrl/Cmd +` / `-` | Font size |
|
||||
| `Ctrl/Cmd+?` | Keyboard help |
|
||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||
@@ -962,6 +982,56 @@ codeman agent rm "$SID" # any session except t
|
||||
|
||||
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
|
||||
|
||||
### MCP server (`codeman mcp`): drive Codeman from any MCP client
|
||||
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so any MCP client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start Codeman sessions in any installed agent CLI, prompt them, wait for them, read their answers and clean up, from inside Codeman or from outside it. Like `codeman agent`, it is a client of the HTTP API: no new route, auth and ownership unchanged.
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `"CODEMAN_PASSWORD"` to `env` when the server has a password (or let it read the data dir's `.env`). Without the `codeman` command on PATH (a git checkout run without the installer), use `node /path/to/Codeman/dist/index.js mcp`.
|
||||
|
||||
| Tool | Does |
|
||||
| ---- | ---- |
|
||||
| `list_sessions` | Every session you can see, with its run mode, status, folder, and whether these tools may prompt it |
|
||||
| `start_session` | A new worker in a case (a fresh scratch folder, or an existing case) in any installed CLI; waits until it can take a prompt |
|
||||
| `send_prompt` | Types a one-line prompt and presses Enter; optionally waits for the end of the turn |
|
||||
| `wait_for_session` | Waits for a signal (`stop`, `blocked`, `idle`, `working`, `exit`) or a literal marker in the output |
|
||||
| `read_output` | The last answer, the whole conversation, or the last bytes of the terminal |
|
||||
| `interrupt_session` | One Escape: stops the current turn, keeps the conversation |
|
||||
| `delete_session` | Closes a worker (its case folder stays) |
|
||||
|
||||
Rules the server enforces rather than documents:
|
||||
|
||||
- **It never guesses a server.** The URL comes from `CODEMAN_API_URL` or `--url`; without one it still starts and lists its tools, and every call says how to configure it.
|
||||
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions this server started (and, run inside a Codeman session, that session's own workers). Listing, reading and waiting work on everything. `codeman mcp --allow-any-session` lifts the limit, for a user who wants an assistant to drive their existing tabs. It never acts on the session it runs in.
|
||||
- **Waits fit the client.** A wait defaults to 45 s (Codex cuts MCP tools off at 60 s by default) and is capped at 600 s; `timed_out` / `still_working` means call again.
|
||||
- **Both protocol eras.** It answers the 2026-07-28 revision (per-request `_meta`, `server/discover`) and the `initialize` handshake of 2024-11-05 to 2025-11-25.
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
|
||||
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
|
||||
</p>
|
||||
|
||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||
@@ -48,11 +48,12 @@ codeman web
|
||||
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
||||
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
||||
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
||||
- **智能体也能驱动它**:[codeman 技能](#智能体技能从这里开始)(适用于所有智能体 CLI)、[`codeman agent`](#codeman-agent每种-cli-模式都能用的会话间命令) 和一个 [MCP 服务器](#mcp-服务器codeman-mcp从任意-mcp-客户端驱动-codeman),让一个智能体在任意 CLI 里拉起工作会话、给它们发提示、等它们完成并收回答案
|
||||
- **什么都不会丢**:tmux 让会话挺过重启和断网,输入精确一次送达,完整的回滚缓冲区回放
|
||||
- **自托管、私有**:默认仅环回、MIT 许可、无遥测,完全运行在你自己的机器上
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
|
||||
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -63,12 +64,13 @@ codeman web
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js、tmux 和一套构建工具链(node-pty 没有 Linux 预编译包,需要从源码编译),把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
||||
该脚本会在缺失时自动安装 Node.js、tmux 和一套构建工具链(node-pty 没有 Linux 预编译包,需要从源码编译),把 Codeman 克隆到 `~/.codeman/app` 并完成构建。它会先看看机器上已有什么,最多问三个问题,然后无人值守地完成所有工作,最后给出访问 URL 和一个供手机扫描的二维码。几点须知:
|
||||
|
||||
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。
|
||||
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。手动运行的 `codeman web` 仍默认仅环回。
|
||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
|
||||
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||
- **三个问题,全部在开头问完。** 仪表盘怎么访问、(可选)这台机器在你的 tailnet 里叫什么,以及是否把 Codeman 作为后台服务运行(systemd/launchd,开机自启;直接回车即为「是」)。所有需要你出面的事,包括对所有缺失软件包的一次性同意、一次 sudo 密码和 Tailscale 登录,都在构建之前完成,所以编译期间你可以走开。
|
||||
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。如果你节点上的 `:443` 已被别的应用占用,Codeman 会改用 `https://<机器名>.<tailnet>.ts.net/codeman` 或第二个端口,而不是把它替换掉。手动运行的 `codeman web` 仍默认仅环回。
|
||||
- **名字由你来定。** 默认情况下,URL 用的是这台机器在 tailnet 里现有的名字。第二个问题答「是」会把机器改名为 `codeman-<hostname>`(这也会改掉它在 SSH 里的名字,所以默认是「否」);之后也可以用 `install.sh name` 来改。
|
||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
|
||||
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
||||
|
||||
@@ -213,6 +215,8 @@ Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.
|
||||
|
||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触;在 Codex 会话上还会显示 `⇧←` / `⇧→`(Shift+Left / Shift+Right:编辑上一条排队的消息 / 在提示栈里回退)
|
||||
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
|
||||
- **附件按钮**:麦克风旁边的图片图标会直接打开系统的照片与文件面板,不会弹出键盘。照片和相册里的视频(mp4、mov、webm,最大 1 GB)会上传到会话所在的文件夹,文件路径落在你的提示里,但不会自动发送
|
||||
- **在 shell 里粘贴**:多行粘贴会作为一整块插入并等你按 Enter,而不是一边到达一边逐行执行
|
||||
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
|
||||
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动;折叠屏手机(iPhone Duo)上对话框会避开铰链,开合设备也绝不会被误判成键盘弹出
|
||||
|
||||
@@ -221,7 +225,7 @@ codeman web --https
|
||||
# 在手机上打开:https://<你的IP>:3000
|
||||
```
|
||||
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐):安装器可以替你配好(在网络访问提示处选择 **Tailscale**,或在已有安装上运行 `bash ~/.codeman/app/install.sh tailscale`)。这样你会得到带真实证书的 `https://<你的机器>.<tailnet>.ts.net`:只对你的 tailnet 可见、无需密码,手机上的 PWA 安装和推送通知也都能用。
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐):安装器可以替你配好(在网络访问提示处选择 **Tailscale**,或在已有安装上运行 `bash ~/.codeman/app/install.sh tailscale`)。这样你会得到带真实证书的 `https://<你的机器>.<tailnet>.ts.net`:只对你的 tailnet 可见、无需密码,手机上的 PWA 安装和推送通知也都能用。安装器最后会给出这个 URL 和一个可扫描的二维码,之后随时可以用 `bash ~/.codeman/app/install.sh status` 再次打印。
|
||||
|
||||
### 安全的二维码认证
|
||||
|
||||
@@ -265,18 +269,18 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
||||
|
||||
### 3. 读懂仪表盘
|
||||
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。更喜欢列表?**App Settings → Appearance → Tabs** 可以把它挪进左侧边栏(带筛选框,`Alt+B` 折叠)或一条竖向导轨,导轨的行按活动状态排序:先是等你处理的,然后是跑得最久的,最后是刚刚安静下来的。
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。更喜欢列表?**App Settings → Appearance → Tabs** 可以把它挪进左侧边栏(带筛选框,`Alt+B` 折叠)或一条竖向导轨,导轨的行按活动状态排序:先是等你处理的,然后是跑得最久的,最后是刚刚安静下来的。导轨上还有一个 **Search sessions** 搜索框、命名的**标签分组**(分组菜单里的 **New session** 会直接在该组里新建会话),以及一个 **Focus** 区,放着你常回去的那些标签的快捷方式。
|
||||
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
||||
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
|
||||
### 4. 与智能体对话
|
||||
|
||||
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
|
||||
- **粘贴或拖放图片**,直接进入会话。
|
||||
- **粘贴、拖放或附加图片和视频**,直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
|
||||
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。
|
||||
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行。
|
||||
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行,包括 `~/` 路径、相对路径和被折成多行的路径。`.html` 报告会以真实网页打开,Markdown 会渲染后打开,文件夹则以其文件的网格打开。
|
||||
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
|
||||
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。
|
||||
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。用 OSC 52 复制的程序(Claude Code 的 `/copy`、tmux 的 yank、vim)也会把文本放进你浏览器的剪贴板。
|
||||
|
||||
### 5. 让它自主运行
|
||||
|
||||
@@ -295,7 +299,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
||||
|
||||
### 7. 运维与维护
|
||||
|
||||
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场动画、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
|
||||
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、终端字体与字重、入场与平铺动画(有自己的 **Animations** 分区)、通知(toast 和浏览器通知的显示时长可调)、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。**Apply** 按钮保存设置但不关闭设置窗口。
|
||||
- **让它在后台运行** —— `codeman web -d` 脱离你的 shell(`--status`、`--stop`);`codeman service install` 把它装成 systemd 用户单元 / macOS LaunchAgent,重启后自动回来。两者都会先确认服务器真正应答再报告成功,也都拒绝在同一个数据目录上启动第二个服务器。见[让它在后台一直运行](#快速开始--安装)。
|
||||
- **自更新** —— git-clone 安装可在 **App Settings → System → Updates** 中原地更新。
|
||||
- **部署你自己的改动** —— 见[开发](#开发)。
|
||||
@@ -388,7 +392,14 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
<img src="docs/images/tile-grid-20261009.gif" alt="平铺网格:点击平铺按钮,六个实时会话并排打开(DeepSeek Harness、Claude Code、Codex、一个 shell、OpenCode 和 Pi),再点击一次即回到单个会话" width="800">
|
||||
</p>
|
||||
|
||||
在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话,网格会被记住,下次直接恢复。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。
|
||||
在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。
|
||||
|
||||
- **先开重要的**:还没有排好网格时,它会先放正在工作的会话,然后是等你处理的,再是最近用过的,并且总会包含你当前所在的会话。
|
||||
- **记住你的布局**:一旦你排好网格(哪个会话在哪、空着的格子、窗格数、分隔条大小、聚焦和放大的窗格),平铺按钮会原样恢复它,网格打开时刷新页面也一样。
|
||||
- **平铺动画**(可选启用):**App Settings → Animations → Tile Animations** 让网格华丽登场:**CRT**(每个窗格像老显像管一样以一道亮线开机,就是顶部 GIF 里那样)、**Fly from tab**、**Deal**、**Beam down**、**Cascade**、**Pop** 或 **Soft**。开启「减少动态效果」时一切静止。
|
||||
- **滚动和主终端一样**:鼠标滚轮会在窗格里滚动全屏 Claude 的对话,Codex 窗格会翻页查看它的记录,`Shift+滚轮` 滚动窗格的本地历史。
|
||||
- **分离窗格(Detach Tiles)**(测试版,可选启用):打开 **App Settings → Appearance → Detach Tiles** 后,把窗格标题栏拖出浏览器,它就弹出成独立窗口;拖到另一个 Codeman 窗口的窗格上,它就移到那边。
|
||||
- **徒手操作**:开启[手势叠加层](#更多特性)后,捏住一个窗格拖到另一个窗格上即可交换,拖到空格子上即可移动,把一个标签拖进网格即可加入。
|
||||
|
||||
### 持久化会话
|
||||
|
||||
@@ -423,7 +434,7 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
|
||||
### 标签提醒(Tab Alerts)
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="会话标签:一个普通的活动标签,旁边是黄色的等待输入标签和红色的需要决定标签,都带着呼吸式光晕" width="900">
|
||||
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="标签状态(带标注):一个绿色描边在转的工作中标签,一个被智能体的问题挡住的红色标签(问题显示在它下方),以及一个回合已完成的黄色标签,两个提醒标签都在呼吸闪烁" width="900"></a>
|
||||
</p>
|
||||
|
||||
每个标签一眼就能看出状态。运行中的会话保持绿色状态点。会话停下来等待输入时,标签变**黄**:稳定的描边、着色的背景、黄色的点,上面叠一层缓慢的呼吸光晕。当权限提示或提问**挡住**了智能体,标签变**红**,脉动更快。底色永远不会闪灭,所以哪怕只瞥一眼(或截一张图)也能读到真实状态;标签被选中时描边依然可见,页面刷新后会从服务端重新装载待处理的提醒,因此一个被挡住的会话绝不可能藏在一个看起来正常的标签后面。
|
||||
@@ -449,8 +460,11 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
## 更多特性
|
||||
|
||||
- **后台守护进程与服务安装** —— `codeman web -d` 以脱离终端的方式运行服务器,带 pid 文件、`~/.codeman/web.log` 和经过校验的启动(它会轮询到服务器应答为止,所以端口冲突绝不会被当成成功);`codeman service install` 写入一个 systemd 用户单元(Linux)或 LaunchAgent(macOS),并把你 shell 的 PATH 一并写进去,这样 nvm 或 Homebrew 装的 `node`、`tmux` 和 `claude` 才真的找得到。机密永远不会写进单元文件
|
||||
- **设置里的诊断**:**App Settings → System → Diagnostics → Run checks** 会在服务器上运行 `codeman doctor`,列出 Node、tmux、每个智能体 CLI 以及可选的 Office 工具,附带版本、路径和安装提示。除了 `PATH`,它还会查找每个 CLI 惯常的安装目录(`~/.local/bin`、`~/.npm-global/bin` 之类),所以即使服务只拿到一个最小的 `PATH`,大多数安装也能被找到。多用户模式下仅管理员可用
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → System → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
|
||||
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
|
||||
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||
@@ -462,10 +476,15 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Terminal & Input 启用
|
||||
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||
- **文件查看器按钮** _(可选)_ —— 页头新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Header & Panels → Header buttons 中启用
|
||||
- **HTML 报告以网页打开**:点击智能体打印出的 `.html` 路径(测试报告、覆盖率页面、仪表盘),文件查看器会把它显示成一个真实网页,并带上同一文件夹里的脚本、样式、图片和数据文件;**Page** 胶囊按钮可切到源码及其 Edit 按钮。页面在沙箱里以不透明源运行,所以它读不到 Codeman 的 cookie、调不了 Codeman 的 API,也读不了自己文件夹以外的文件。网络访问保持开放,因此从 CDN 加载 Chart.js 或字体的仪表盘和直接从磁盘打开时一样能用。范围过大的文件夹(`/`、你的主目录、`/tmp`)会被拒绝,所以请给报告一个单独的文件夹。在终端里点击的 `.md` 路径也会渲染后打开
|
||||
- **可点击的路径与文件夹网格**:写成 `~/…`、相对于会话文件夹或被折成多行的路径,在终端和回复查看器(Response Viewer)里都能点击,文件夹也可以:点击文件夹会打开它的文件网格(渲染图、截图、报告),用方向键翻看
|
||||
- **OSC 52 剪贴板**:会话里的程序用 OSC 52 设置剪贴板时(Claude Code 的 `/copy`、tmux 的 yank、vim),文本会进入你浏览器的剪贴板,主终端和每个平铺窗格都一样。读取剪贴板的请求一律拒绝,回放缓冲区也绝不会触发复制
|
||||
- **MCP 服务器同步**(可选启用):把每个已安装 CLI 的用户级 MCP 服务器(Claude、Codex、Gemini、OpenCode、Antigravity,以及 GitHub Copilot CLI)复制进其他 CLI 自己的配置文件。只增不改,你关掉的服务器绝不会被复制,**Preview** 会先列出改动,原文件保留为 `.codeman-bak`。位于 App Settings → Agents & CLIs;多用户模式下仅管理员可用
|
||||
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入,Ctrl、Alt 修饰的导航键也会原样透传给 CLI
|
||||
- **页头里的套餐用量** —— 页头实时显示 Claude 订阅用量(5 小时窗口与每周窗口),数据来自 Codeman 在拉起 `claude` 时临时交给它的 statusline 导出器,绝不会写进你的设置文件;Codex 的限额则来自它自己的 app-server。按设备生效:桌面默认开,手机默认关
|
||||
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序
|
||||
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的入场动画,覆盖标签、智能体窗口、终端面板和连接线
|
||||
- **提示缓存状态**:每个 Claude 会话都会显示它的提示缓存是热的(`cache:until HH:MM`)还是冷的,显示在页脚(前提是你没有自己的 statusline)和 Session Options → Respawn 中,并给出重新缓存的代价,压缩上下文后还会附一条说明。只做显示,重启后依然保留
|
||||
- **会话列表,随你摆** —— 页头横条、带筛选框的左侧边栏,或一条竖向导轨,导轨的详细行带有创建时间与状态时长并按活动状态排序;手机首页和桌面首页导轨用的是同一套顺序。导轨上的 **Search sessions** 会把列表筛到名字匹配的标签,跨所有分组;带提醒的标签即使名字不匹配也始终可见。命名的**标签分组**可以折叠、重排和拖入拖出;分组菜单里有 **New session**,智能体拉起的工作会话会落在它的父会话后面(**Spawned tabs follow their parent**,默认关闭,开启后它会跟着父会话移动)。**Add to Focus** 会在导轨顶部的一个区里为任意标签放一个快捷方式,**CLI Logos on Tabs** 可以按设备关闭
|
||||
- **终端外观** —— 七套皮肤(其中四套浅色)、按设备保存的字体与字重(内置的 JetBrains Mono 覆盖 100 到 800 的字重),以及可选启用的动画,覆盖标签、智能体窗口、终端面板、连接线和[平铺网格的窗格](#平铺网格),全部位于 App Settings → Animations(附带一个试玩用的动画实验室)
|
||||
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
|
||||
|
||||
---
|
||||
@@ -480,7 +499,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
|
||||
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Antigravity / Gemini / OpenCode / Pi / Grok / OMP 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
|
||||
- **挂到你已经在跑的容器上** —— 在 Docker 面板勾选 **Attach to an existing container**,就能把 case 链接到一个现成容器,而不是新建一个。Codeman 只 `exec` 进去,绝不启动、停止、重启或删除它;一个被接管的容器可以在不同目录下支撑多个 case,**复制一个已有 case** 会用同一容器上的兄弟 case 预填表单。多用户模式下仅管理员可用,因为容器的挂载属于启动它的人。
|
||||
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
|
||||
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上用 `docker load` 载入,再导入到一个新案例即可继续。
|
||||
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
|
||||
|
||||
前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。
|
||||
@@ -497,6 +516,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
|
||||
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
|
||||
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
|
||||
- **完整的回滚历史**:加载远程会话的历史时,会通过一次有上限的 ssh 往返直接从远程 tmux 读取,所以重新附着或切换标签之后,会话依然保有完整的回滚缓冲区,历史与实时屏幕衔接处也不会丢行。
|
||||
|
||||
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
|
||||
|
||||
@@ -649,7 +669,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
||||
|
||||
### 网络与访问
|
||||
|
||||
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
||||
- **默认仅环回** —— 服务器程序本身绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全(引导式安装器会询问网络访问方式,并替你配置绑定和密码)。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
||||
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
|
||||
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
|
||||
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Agents & CLIs → Claude → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
|
||||
@@ -667,7 +687,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
||||
### 输入、文件与响应头
|
||||
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行。HTML 唯一会被渲染的地方是文件查看器的网页视图:一个独立的 `/html-view/<capability>/` 地址,只覆盖该文件所在的文件夹,响应带 `Content-Security-Policy: sandbox` 且不含 `allow-same-origin`,因此页面是不透明源,永远读不到 Codeman 的 cookie,也驱动不了它的 API;隐藏文件夹和范围过大的根目录(`/`、主目录、`/tmp`)会被拒绝,退出登录时这些凭证也会被吊销
|
||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||
|
||||
### 供应链与隔离
|
||||
@@ -701,7 +721,7 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
|
||||
| 快捷键 | 动作 |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `Ctrl/Cmd+W` | 杀掉当前会话 |
|
||||
| `Ctrl+W` | 不是 Codeman 快捷键:它会到达终端(删除前一个词)。**Close Session** 默认没有按键,可在 App Settings → Shortcuts 里绑定 |
|
||||
| `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 |
|
||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
|
||||
@@ -714,6 +734,10 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
| `Ctrl+Shift+G` | 打开或关闭平铺网格 |
|
||||
| `Alt+Shift+方向键` | 聚焦左、右、上、下方的窗格 |
|
||||
| `Ctrl+Shift+方向键` | 把聚焦的窗格移动一格(移进空格子,或与邻居交换) |
|
||||
| `Alt+Shift+Enter` | 放大聚焦的窗格,或恢复网格 |
|
||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||
@@ -731,19 +755,23 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
|
||||
|
||||
### 智能体技能(从这里开始)
|
||||
|
||||
这一节的所有内容也打包成了一个 **Claude Code 技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md)。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。
|
||||
这一节的所有内容也打包成了一个**智能体技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md),采用 Claude Code、Codex、Gemini CLI、OpenCode、Grok、OMP 以及大多数其他智能体 CLI 都能读取的 `SKILL.md` 格式。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="一次真实的 codeman 技能运行:在 Claude Code 里输入一句简短的提示,平铺网格开机,然后一个跑在本地 qwen 模型上的 DeepSeek Harness 工作会话和一个 Claude Code 工作会话作为新窗格开机,主会话到两者之间画出派生连线" width="900"></a>
|
||||
</p>
|
||||
|
||||
#### 第 1 步:安装
|
||||
|
||||
| 方式 | 命令 | 范围 |
|
||||
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用 |
|
||||
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用([skills.sh](https://skills.sh));给 pi 装技能就用这个方式 |
|
||||
| Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) |
|
||||
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
|
||||
| 内置 CLI | `codeman skill install --case <name>` | 仅一个 case |
|
||||
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | 每次在某个 case 创建 Claude 会话时自动注入(`agentSkillEnabled`,跨设备同步,默认关闭) |
|
||||
| 内置 CLI | `codeman skill install --case <name> [--mode <mode>]` | 仅一个 case;`--mode codex`(或任何其他运行模式)会把它写到那个 CLI 查找技能的位置 |
|
||||
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,默认关闭) |
|
||||
|
||||
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装,并且绝不会碰你自己写的 `skills/codeman`。
|
||||
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装(带 `--case` 时,删除该 case 里所有由 Codeman 管理的副本,不论它在哪个 CLI 的目录里),并且绝不会碰你自己写的 `skills/codeman`。
|
||||
|
||||
#### 第 2 步:开口要
|
||||
|
||||
@@ -796,7 +824,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
|
||||
- **它会自我门禁。** 不在 Codeman 会话里(`CODEMAN_MUX` 未设置)时,技能拒绝动作,也不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
|
||||
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
|
||||
|
||||
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个 `.claude/` 目录的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
|
||||
|
||||
---
|
||||
|
||||
@@ -933,6 +961,76 @@ codeman tui --list # 带编号的会话列表(管
|
||||
codeman tui 3 # 附着到该列表里的第 3 个会话
|
||||
```
|
||||
|
||||
### `codeman agent`:每种 CLI 模式都能用的会话间命令
|
||||
|
||||
上面的技能靠 Codeman 为每个会话写入的一段前导脚本,用 `curl` 和 `jq` 驱动 API。在它够不着的地方(`pi` 或 `shell` 会话、没有 `jq` 的机器),同样的环境依然存在(每个面板里都导出了 `CODEMAN_MUX=1`、`CODEMAN_SESSION_ID`、`CODEMAN_API_URL`),所以 `codeman agent` 把同一批动词打包成了命令。它只是 [API](#api) 一节所列端点之上的一层薄客户端:没有新路由,没有新的传输方式,认证和归属规则不变。在 case 的 `AGENTS.md` 里写一行就够了:*"other sessions: `codeman agent --help`"*。
|
||||
|
||||
```bash
|
||||
codeman agent ls # 列出会话;* 标记当前这个
|
||||
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start,并在该模式有就绪标记时等到输入框出现
|
||||
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = 默认信号集
|
||||
codeman agent read "$SID" # 最后一条回答(按服务端对该模式的读取方式)
|
||||
codeman agent read "$SID" --tail 3000 # 终端尾部,已去掉 ANSI(所有模式)
|
||||
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # 没有 hook 的模式(opencode、pi 等):让它分两半输出标记……
|
||||
codeman agent wait "$SID" --match WORKDONE_4711 # ……然后等拼接后的形式,提示本身的回显里永远不会出现它
|
||||
codeman agent interrupt "$SID" # 只发一个 ESC,对话保持完整
|
||||
codeman agent rm "$SID" # 除当前会话外的任何会话
|
||||
```
|
||||
|
||||
这些规则由命令强制执行,而不只是写在文档里:在 Codeman 会话之外它们拒绝运行,也绝不猜测 URL;`send` 只发送可打印文本加 Enter(像 `Ctrl+C` 这样的控制字节在 opencode 里就是 `app_exit`,所以 ESC 只以 `interrupt` 的形式存在,而且它从不追加 Enter);`rm` 拒绝空 id、无法证明不是自己的 id,以及任一方向的前缀匹配。id 可以用 `ls` 打印的 8 字符前缀(通过列表解析;有歧义的前缀会被拒绝,短于 8 个字符的 id 在所有动词上都会被拒绝)。以 `-` 开头的提示要放在 `--` 之后(`send "$SID" -- "- fix the bug"`)。
|
||||
|
||||
你发出的提示的回显也算输出:如果 `--match` 标记原样出现在提示里,它会立刻匹配,工作会话还什么都没做,所以提示要让它分两半输出。主机正在休眠的远程会话,对不等待结果的 `send` 会回答 `buffered`(Codeman 唤醒主机,等面板恢复后再敲入提示)或 `dropped`(超出唤醒缓冲区的上限,什么都不会敲入:退出码 `1`)。退出码:`0` 已送达/已匹配/收到信号,`1` 错误,`2` 超时,`3` 工作会话已退出或等待在没有回答的情况下结束(`delivered:false`、`ended:true`),`4` 被拒绝。`spawn` 在 stdout 上只打印 id(说明文字走 stderr),所以 `SID=$(…)` 恰好拿到 id。`--json` 让每个动词打印响应信封里的 `data`。在没有 hook 信号的模式上使用 `--until stop`,会原样透传服务端的 400:那里的答案是标记路径(`--match`),和技能完全一样。
|
||||
|
||||
### MCP 服务器(`codeman mcp`):从任意 MCP 客户端驱动 Codeman
|
||||
|
||||
`codeman mcp` 是一个走 stdio 的 [Model Context Protocol](https://modelcontextprotocol.io) 服务器,因此任何 MCP 客户端(Claude Code、Claude Desktop、Codex、Gemini CLI、Cursor 等)都能在任意已安装的智能体 CLI 里启动 Codeman 会话、给它们发提示、等它们完成、读取回答并清理,无论是在 Codeman 内部还是外部。和 `codeman agent` 一样,它是 HTTP API 的客户端:没有新路由,认证和归属规则不变。
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex(~/.codex/config.toml):为长时间等待调高默认 60 秒的工具超时
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex 只给 MCP 服务器一个最小环境;这两个变量让它知道自己运行在哪个 Codeman 会话里
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
JSON 形式适用于 Claude Desktop、Cursor 和 Gemini CLI。服务器设置了密码时,在 `env` 里加上 `"CODEMAN_PASSWORD"`(或者让它读取数据目录里的 `.env`)。如果 PATH 上没有 `codeman` 命令(没走安装器的 git 检出),改用 `node /path/to/Codeman/dist/index.js mcp`。
|
||||
|
||||
| 工具 | 作用 |
|
||||
| ---- | ---- |
|
||||
| `list_sessions` | 你能看到的每个会话,附带运行模式、状态、文件夹,以及这些工具是否可以给它发提示 |
|
||||
| `start_session` | 在一个 case 里(全新的临时文件夹或已有的 case)用任意已安装的 CLI 起一个新工作会话;等到它能接收提示为止 |
|
||||
| `send_prompt` | 输入一行提示并按 Enter;可选择等到这一回合结束 |
|
||||
| `wait_for_session` | 等待一个信号(`stop`、`blocked`、`idle`、`working`、`exit`)或输出里的一个字面标记 |
|
||||
| `read_output` | 最后一条回答、整段对话,或终端的最后若干字节 |
|
||||
| `interrupt_session` | 一次 Escape:停止当前回合,保留对话 |
|
||||
| `delete_session` | 关闭一个工作会话(它的 case 文件夹会保留) |
|
||||
|
||||
这些规则由服务器强制执行,而不只是写在文档里:
|
||||
|
||||
- **它绝不猜测服务器。** URL 来自 `CODEMAN_API_URL` 或 `--url`;没有配置时它照样启动并列出工具,每次调用都会说明该如何配置。
|
||||
- **只动自己的会话。** 发提示、中断和删除只限于这个服务器自己启动的会话(以及它运行在某个 Codeman 会话里时,该会话自己的工作会话)。列出、读取和等待对所有会话都可用。`codeman mcp --allow-any-session` 会解除这个限制,适合想让助手驱动自己现有标签的用户。它永远不会对自己所在的会话下手。
|
||||
- **等待时长适配客户端。** 一次等待默认 45 秒(Codex 默认在 60 秒时切断 MCP 工具),上限 600 秒;返回 `timed_out` / `still_working` 时再调用一次即可。
|
||||
- **两个协议时代都支持。** 它既能应答 2026-07-28 版本(每个请求带 `_meta`、`server/discover`),也能应答 2024-11-05 到 2025-11-25 的 `initialize` 握手。
|
||||
|
||||
### Hook(事件*回流*到 Codeman)
|
||||
|
||||
Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.41.1",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
"source": "github"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.41.1",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"packageArguments": [
|
||||
{
|
||||
"type": "positional",
|
||||
"value": "mcp"
|
||||
}
|
||||
],
|
||||
"environmentVariables": [
|
||||
{
|
||||
"name": "CODEMAN_API_URL",
|
||||
"description": "Where your Codeman server runs, for example https://127.0.0.1:3000. Never guessed.",
|
||||
"isRequired": true,
|
||||
"format": "string"
|
||||
},
|
||||
{
|
||||
"name": "CODEMAN_PASSWORD",
|
||||
"description": "Codeman's HTTP Basic password, when the server has one.",
|
||||
"isRequired": false,
|
||||
"isSecret": true,
|
||||
"format": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/tab-layout-editing.browser.test.ts',
|
||||
'test/tab-rail-search.browser.test.ts',
|
||||
'test/tab-focus.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
@@ -35,6 +36,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/terminal-tile-scroll.browser.test.ts',
|
||||
'test/terminal-touch-paging.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
@@ -47,6 +49,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/mobile-ime-preview.browser.test.ts',
|
||||
'test/run-mode-menu-scroll.browser.test.ts',
|
||||
'test/markdown-anchor-links.browser.test.ts',
|
||||
'test/fullscreen-tab-switch-capture.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,8 +43,9 @@ payload return `{ "success": true, "data": {} }`.
|
||||
|
||||
> Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`,
|
||||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect),
|
||||
> `GET /html-view/:cap/*` (a [rendered HTML file](#rendered-html-files) and its
|
||||
> assets), and the `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||||
>
|
||||
> **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
|
||||
> `GET /api/screenshots/:name` keep working but log a one-time warning on first
|
||||
@@ -543,20 +544,31 @@ client that opens many concurrent waits against one session will still hit the c
|
||||
|
||||
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
|
||||
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
|
||||
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
|
||||
geometry was read. The capture runs synchronous tmux calls on the server; the
|
||||
`truncationReason`, `fullSize`, `historyLinesCut` (a `lines=` bound left older
|
||||
lines in tmux, so the remainder is more than `fullSize - retainedBytes`), and
|
||||
`captureCols`/`captureRows` when the pane's geometry was read. `paneHistoryLines`
|
||||
(present when the body is a pane capture whose geometry was read, like
|
||||
`captureCols`/`captureRows`, and absent for a remote SSH session, whose local pane
|
||||
cannot see the remote history) is the number of scrollback rows tmux holds above the
|
||||
visible frame, which is the most a `full=1` request can add. `truncated` describes
|
||||
the byte stream instead: for a pane with `paneHistoryLines: 0` (a fullscreen CLI in
|
||||
the alternate screen) the bytes a `tail` cut dropped are earlier repaints that no
|
||||
request returns. The capture runs synchronous tmux calls on the server; the
|
||||
`Server-Timing` header reports `capture`, `prepare` and `total`.
|
||||
|
||||
| Query | Meaning |
|
||||
|---|---|
|
||||
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
|
||||
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. |
|
||||
|
||||
## The `codeman agent` CLI (client over these endpoints)
|
||||
|
||||
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
|
||||
|
||||
`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.
|
||||
|
||||
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
|
||||
|
||||
A `multipart/form-data` body with one `image` part. The file is written into the
|
||||
@@ -574,6 +586,64 @@ owned case is fine, its workspace is bind-mounted at the same absolute path; an
|
||||
adopted container (`owned: false`) mounts nothing, so its agent can open the file
|
||||
only if the container itself exposes that host path.
|
||||
|
||||
The optional query flag `?kind=video` selects a second path for a camera-roll
|
||||
video: the file streams to disk instead of being buffered, under a far larger
|
||||
per-file cap (`MAX_PASTE_VIDEO_BYTES`, 1 GB by default, env
|
||||
`CODEMAN_MAX_PASTE_VIDEO_BYTES`), and only video container types are accepted
|
||||
(mp4, webm, mov, m4v, ogv, single-sourced from the file viewer's playable set).
|
||||
The container is checked from the first bytes as
|
||||
the body arrives; a file whose bytes do not match its declared type is unlinked
|
||||
and answered 415, one over the cap is unlinked and answered 413, and a transfer
|
||||
that dies midway leaves nothing behind. Because one video can be 1 GB,
|
||||
`?kind=video` draws on its own rate limit, 3 uploads per minute per (IP, session),
|
||||
beside the 30 per minute the image path keeps; without the flag the route is
|
||||
byte-identical to the image-only version, same 50 MB image cap and same 30/min
|
||||
bucket. The response shape is unchanged (`path`, `filename`).
|
||||
|
||||
## Rendered HTML files
|
||||
|
||||
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
|
||||
(`src/web/routes/html-view-routes.ts`).
|
||||
|
||||
**`POST /api/v1/sessions/:id/html-view`** with `{ "path": "<file>" }` (absolute,
|
||||
relative to the session's workspace, or `~/` for this host's home, which a Docker
|
||||
case refuses with `400` since its `~` is the container's; a non-empty string of at most 4096 characters
|
||||
without NUL or line breaks, and no other keys) answers `data: { url }`, a
|
||||
`/html-view/<cap>/<file>` URL. The capability grants GET access to the files under
|
||||
that file's own folder, so a page's relative CSS, scripts, images and data files
|
||||
resolve. It is held in memory only (a restart invalidates it), has a rolling TTL
|
||||
(`CODEMAN_WEBVIEW_CAPABILITY_TTL_MS`, default 12 h), is reused for the same folder,
|
||||
and is revoked on logout, admin logout and user deletion. Refusals:
|
||||
|
||||
- `400`: an invalid body, a remote (SSH) session, or a file that is not HTML (the
|
||||
symlink-resolved name counts too);
|
||||
- `404`: no such file;
|
||||
- `403`: a file outside the workspace that the attachment guard blocks (a blocked
|
||||
tree, or `attachmentConfineToWorkspace` on), a file in a hidden directory (inside
|
||||
the workspace, one below the workspace root), or a file directly in a folder too
|
||||
broad to serve recursively: `/`, the home folder, `/tmp`, `/var/tmp`, `$TMPDIR`,
|
||||
the cases and user-space roots, a drive's mount point (`/mnt/d`), or any folder
|
||||
above one of those (`/home`, `/Users`).
|
||||
|
||||
**`GET /html-view/:cap/*`** serves the page and its assets. It needs no cookie: the
|
||||
capability in the path is the credential, because the page runs in an opaque origin
|
||||
that never sends one. It is not under `/api`, so it has no `/api/v1` alias and no
|
||||
envelope. `GET` and `HEAD` only; a CORS preflight on a live capability is answered
|
||||
with `Access-Control-Allow-Methods: GET, HEAD`. What it serves: files under the
|
||||
capability's folder whose path (requested and symlink-resolved) has no dot segment
|
||||
and whose extension is on an asset allowlist (html, css, js/mjs, json, map, txt,
|
||||
csv, md, svg and common image, font, wasm, audio, video and PDF types), never
|
||||
outside the folder, never a blocked tree, and, for a folder outside the workspace,
|
||||
not while `attachmentConfineToWorkspace` is on. Every response carries
|
||||
`Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads`
|
||||
as its whole policy (never `allow-same-origin`, so the page cannot read Codeman's
|
||||
document, cookies or API, even opened as a top-level tab; and no fetch directive, so
|
||||
by the owner's decision the page may load from and send requests to any host), `Cache-Control: no-store`,
|
||||
`Referrer-Policy: no-referrer` and `Access-Control-Allow-Origin: *`. A `Range`
|
||||
request is answered with `206` + `Content-Range` (`416` past the end), like
|
||||
`file-raw`. Every refusal is a plain-text `404 Not found`; with a password set, an
|
||||
unknown or revoked capability is the auth hook's `401` instead.
|
||||
|
||||
## Session lineage (`parentSessionId`)
|
||||
|
||||
A create request may name the session that spawned it, which the web UI draws as a
|
||||
@@ -625,6 +695,52 @@ most 64 characters, but treat it as untrusted text. A `statusline` or `screen` v
|
||||
persisted and restored after a server restart until the next report replaces it; a
|
||||
`config` value is read again at every pane start, attach and relaunch instead.
|
||||
|
||||
## Prompt cache (`promptCache`)
|
||||
|
||||
Claude-mode session state (`GET /api/v1/sessions`) carries the main conversation's
|
||||
prompt-cache state as Claude Code last reported it on the statusline (v2.1.251+), with a
|
||||
change-only `session:promptCache` event (`{ sessionId, ...promptCache }`):
|
||||
|
||||
```json
|
||||
"promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 }
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `warm` | Whether the cached prefix is still within its TTL. The only field always present. |
|
||||
| `ttl` | `5m` or `1h`; absent when Claude reported neither. |
|
||||
| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. |
|
||||
| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. |
|
||||
| `misses` | Requests that re-processed content the cache already held. |
|
||||
| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. |
|
||||
| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. |
|
||||
| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. |
|
||||
| `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. |
|
||||
|
||||
The whole object is absent until the first report, and restored after a server restart
|
||||
(so the readout is not blank until the pane's next render) rather than recomputed; a value
|
||||
whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm cache hits
|
||||
`expiresAt`, so the cold flip arrives on its own. A post-`/clear` conversation switch drops
|
||||
it. Shown in Session Options → Respawn and, for sessions without a statusline of their own,
|
||||
as a `cache:until HH:MM` / `cache:cold` group in the terminal footer.
|
||||
|
||||
Beside it, `statusLineExporter` says whether this launch carries the statusline exporter
|
||||
that sends the report, decided when the pane starts: `injected` (it does), `collection-off`
|
||||
(Plan Usage was off at launch), `workspace-statusline` (the workspace's
|
||||
`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or
|
||||
`remote` (remote and Docker sessions). Absent when unknown.
|
||||
|
||||
## Placing a new session in a tab group (`tabGroupId`)
|
||||
|
||||
`POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional
|
||||
`tabGroupId` (1 to 100 characters): the id of a group in the caller's tab layout
|
||||
(`GET /api/v1/tab-layout`). The session is created as usual and placed at the end
|
||||
of that group in the same layout write; the response then also carries the
|
||||
committed layout as `tabLayout`. An id the caller's layout does not contain (a
|
||||
deleted group, another user's group) is ignored and the session lands where it
|
||||
otherwise would, never a `400`. A malformed value (`null`, an empty string, a
|
||||
non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged.
|
||||
|
||||
## Approvals Inbox
|
||||
|
||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||
@@ -895,6 +1011,42 @@ Both routes accept two optional query parameters, which the UI sends from its pe
|
||||
|
||||
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||
|
||||
## Git worktrees of a case
|
||||
|
||||
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
|
||||
|
||||
`GET /api/cases/:name/worktrees` lists `git worktree list` for the case's repository:
|
||||
|
||||
```json
|
||||
{
|
||||
"caseName": "myrepo",
|
||||
"repoRoot": "/home/me/codeman-cases/myrepo",
|
||||
"worktreesDir": "/home/me/codeman-cases/myrepo/.claude/worktrees",
|
||||
"truncated": false,
|
||||
"worktrees": [
|
||||
{ "path": "/home/me/codeman-cases/myrepo", "head": "6910cb15…", "branch": "main", "detached": false, "bare": false,
|
||||
"locked": false, "prunable": false, "main": true, "isCase": true, "managed": false, "sessionCount": 0, "sessions": [] },
|
||||
{ "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", "branch": "worktree-fix-login",
|
||||
"main": false, "isCase": false, "managed": true, "name": "fix-login",
|
||||
"sessionCount": 1, "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`managed` is `true` only for `<repoRoot>/.claude/worktrees/<name>`: those are the ones Codeman starts sessions in and removes; any other worktree of the repository is listed for information. `sessionCount` counts live sessions of every owner running in that worktree (a session belongs to the deepest worktree holding its folder), `sessions` lists the ones the caller may see. `lockReason`/`prunableReason` appear when git reports them.
|
||||
|
||||
`POST /api/cases/:name/worktrees` with `{ name, branch?, base? }` creates one (`201`, `{ worktree: { name, path, branch, base, head, excludeAdded, warnings } }`):
|
||||
|
||||
- `name`: 1 to 64 characters of `A-Z a-z 0-9 . _ -`, starting with a letter or digit, no `..`, not ending in `.` or `.lock`. `400` otherwise.
|
||||
- `branch`: the NEW branch, default `worktree-<name>`. Letters, digits, `.`, `_`, `-`, `/`, then `git check-ref-format --branch` must accept it unchanged. An existing branch is `409 ALREADY_EXISTS` (never reset), and so is a name whose folder already exists.
|
||||
- `base`: where the branch starts, default `HEAD` (the case's current commit): a branch, tag or commit, resolved before anything is written (`400` when it is not a commit, which includes an empty repository).
|
||||
- `excludeAdded` lists the lines appended to the repository's local `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git did not already ignore them: `/.claude/worktrees/`, so the main checkout does not show the worktrees as untracked, `/.claude/settings.local.json`, the hooks file Codeman writes for every claude session, which would otherwise make `git worktree remove` refuse, and the agent skill copy (`/.claude/skills/codeman/`, `/.agents/skills/codeman/`: every skills folder the CLI registry names) that sessions get while `agentSkillEnabled` is on, for the same reason.
|
||||
- A symlinked `.claude` or `.claude/worktrees` directory is refused (`400`): a repository cannot redirect the worktree outside itself.
|
||||
|
||||
`DELETE /api/cases/:name/worktrees/:worktree[?deleteBranch=true]` removes a managed worktree with `git worktree remove` **without `--force`**: git refuses a worktree with modified or untracked files (or a locked one), and that refusal is the answer, `409 CONFLICT` with git's own words in `error`. A live session at or inside the worktree is `409` as well. With `deleteBranch=true` the branch is deleted with `git branch -d`, never `-D`, so an unmerged branch is kept: the response is still `200`, with `branchDeleted: false` and git's reason in `branchError`. Response: `{ removed: { name, path, branch, branchDeleted, branchError? } }`. A folder git does not list as a worktree of this repository, or a symlink in its place, is `404`/`400`. A managed worktree whose folder is gone (deleted by hand; the list reports it `prunable`) is still removable: git then only drops its record, which is what frees the name for a new worktree. A session never starts in one.
|
||||
|
||||
**Starting a session in a worktree** goes through the normal create paths: `POST /api/quick-start` takes `worktree: "<name>"` next to `caseName` and runs the session (and the hooks, the agent skill and `modelOverride`) in the worktree folder; the response adds `worktree` and `workingDir`. It is refused for remote and Docker cases (`400`), for a worktree that is not a registered, managed one (`404`), and for a case that does not exist (`404`: a worktree launch never scaffolds a case). `POST /api/sessions` takes the worktree path as `workingDir` like any other folder.
|
||||
|
||||
## CLI management
|
||||
|
||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists; newer ones are written here first and summarized back into `CLAUDE.md` as a short rule plus a pointer.
|
||||
|
||||
`CLAUDE.md` keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint.
|
||||
`CLAUDE.md` keeps the short form of each rule plus a pointer to the section here; for the frontend (`src/web/public/`) that short form lives in `src/web/CLAUDE.md`, which the root file points to. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint.
|
||||
|
||||
---
|
||||
|
||||
@@ -77,7 +77,7 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only
|
||||
|
||||
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
|
||||
|
||||
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links.
|
||||
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
|
||||
|
||||
### Remote SSH cases
|
||||
|
||||
@@ -157,7 +157,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent
|
||||
|
||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`.
|
||||
|
||||
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`.
|
||||
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
|
||||
|
||||
### Cron jobs
|
||||
|
||||
@@ -209,9 +209,13 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
||||
|
||||
### Full-scrollback replay
|
||||
|
||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell FULL history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. That gesture instead pulls a BOUNDED window of it (`?full=1&tail=<TERMINAL_TAIL_SIZE>`, so a capture beyond 1 MiB comes back `truncationReason: 'tail'` and the banner offers the rest), and skips the replay when the window holds no more rows than the browser already has. ⚠️ **That skip runs BEFORE the downgrade guard, and it never touches the banner state**: the guard reads "smaller than the browser" as "tmux has nothing more to give", 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 removed **Load full history** while tmux still held the rest; and re-labelling the banner from a skipped payload would call a terminal that holds all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated puts the session on the 60 s cooldown (`_fullHistoryRepullUseless`), because it can never reach anything older than the browser shows and `tail` is applied AFTER the server's synchronous `capture-pane` of the whole history, so each ask costs every client an event-loop stall (about 0.3 s at 100k lines); an untruncated one keeps the 4 s cooldown, since it is all of tmux's history and the next burst can add to it. Declining the gesture outright was a dead end: after any burst a shell pane held about one screen of browser scrollback, and the button renders only once a replay was truncated, so a young shell tab had no way back to history tmux still held. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, and the marker callback supplies accurate parse timing. ⚠️ **How the load ENDS depends on where the payload came from**, and `_bufferLoadFinishOpts` (app.js) is the one place that decides it for all four fetch-and-write paths. A payload built from the server's accumulated byte history is current up to the response, so the events queued during the load already appear in it and stay DISCARDED; replaying them would duplicate output, most visibly Ink's cursor-up redraws. A pane capture (`mux-visible` or `mux-full-history`) is current only up to CAPTURE time, so `_finishBufferLoad` replays the queue from the response's own arrival timestamp (`since`) and the pre-capture events stay dropped. ⚠️ **A path that then restores a scroll position must re-take the sticky-scroll baseline** (`_syncStickyScrollBaseline`): the replay runs inside `chunkedTerminalWrite` before its promise resolves, with the terminal freshly reset, so `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` as true and the next `flushPendingWrites` would scroll to the bottom over the restore. ⚠️ The cutoff is a client-side timestamp and the server broadcasts on a batch timer (8ms WebSocket, 16-50ms SSE), so a batch pending when the capture ran arrives after the response and replays although the capture holds it — bounded by one batch interval, and closable only server side by flushing that batch before the capture. Tests for the three: `test/terminal-flush-budget.test.ts` pins which sources flush, `test/terminal-buffer-flush.test.ts` pins the `since` cutoff and the baseline re-take, and `test/capture-load-window.browser.test.ts` drives both against a live server. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the Line truncated
|
||||
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded window of tmux's RENDERED history on every selection, automatic drop recovery, clear and tile load (`SHELL_LOAD_QUERY` in constants.js: `?full=1&tail=1MiB&lines=10000&alt=recording`): a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session (`lines=` bounds the synchronous capture-pane, about 8 ms per 1000 lines). They never replay the raw byte recording ahead of the visible frame any more: it holds full-screen repaints (an ssh'd tmux, `less`, any TUI) drawn for the pane height of the moment, and replayed into a terminal of another height each repaint's last newline scrolls its top row into scrollback, so one line tmux holds once showed up dozens of times. Because `lines=` is shallower than xterm's scrollback, the route reports a cut: the capture's `display-message` query also reads `#{history_size}` (`PaneCaptureOptions.capturedHistoryLines`, the same field the route reports as `paneHistoryLines`), and a full capture whose CLIENT bound (`lines=` below the configured `tmuxHistoryLimit`) left lines in tmux comes back `truncated`, `truncationReason: 'tail'`. ⚠️ Only a client bound below the ceiling counts: a request without `lines=` is bounded by the ceiling itself, and on tmux before 3.7 a live pane can hold more than a lowered ceiling, which no request can read. ⚠️ **A pane in the alternate screen keeps no tmux history**, and a nested tmux client (remote SSH and Docker cases, or `tmux`/`ssh -t host tmux` run by hand) holds its pane there, so its full capture is one screen. The same query reads `#{alternate_on}` (`capturedAlternateOn`), and a request carrying `alt=recording` (only `SHELL_LOAD_QUERY`) is then answered the old way, the visible frame over the tailed byte recording, which still carries the scrollback; agents, scroll pulls and **Load full history** never send it. `#{alternate_on}` cannot tell a nested tmux from vim, `less` or `htop`, so a local shell with one of those open takes the same fallback until it exits (the pre-fix load, with its repaint duplicates). Shell FULL history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. That gesture instead pulls a BOUNDED window of it (`?full=1&tail=<TERMINAL_TAIL_SIZE>`, so a capture beyond 1 MiB comes back `truncationReason: 'tail'` and the banner offers the rest), and skips the replay when the window holds no more rows than the browser already has. ⚠️ **That skip runs BEFORE the downgrade guard, and it never touches the banner state**: the guard reads "smaller than the browser" as "tmux has nothing more to give", 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 removed **Load full history** while tmux still held the rest; and re-labelling the banner from a skipped payload would call a terminal that holds all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated puts the session on the 60 s cooldown (`_fullHistoryRepullUseless`), because it can never reach anything older than the browser shows and `tail` is applied AFTER the server's synchronous `capture-pane` of the whole history, so each ask costs every client an event-loop stall (about 0.3 s at 100k lines); an untruncated one keeps the 4 s cooldown, since it is all of tmux's history and the next burst can add to it. Declining the gesture outright was a dead end: after any burst a shell pane held about one screen of browser scrollback, and the button renders only once a replay was truncated, so a young shell tab had no way back to history tmux still held. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; outpLine truncated
|
||||
|
||||
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
|
||||
**The partial-history notice is lazy and pane-gated** (`computeHistoryTruncationNotice()` in constants.js, `_renderHistoryTruncationBanner()` / `_setHistoryNoticeRevealed()` in app.js). `truncated` on a terminal response measures the server's BYTE stream, which is not what `full=1` can return: for a pane with no tmux scrollback (fullscreen claude starts in the alternate screen and never writes to the main grid, so `#{history_size}` is 0) the bytes a tail cut drops are old repaint frames. Measured on prod 2026-10-10, every claude pane: the tab-switch tail cut a 1.2 to 6.2 MB stream to 1 MiB, the local scrollback that tail replays into is repeats of the same frames (one pane: 450 non-blank rows, 44 unique, one row 33 times), and `full=1` returned 1 to 5 KB, so **Load full history** could only end in the downgrade refusal while the banner promised "4.8 MB more". The capture therefore reads `#{history_size}` in the SAME `display-message` as the cursor (no extra tmux call), the route returns it as `paneHistoryLines` (absent when the body is not a capture, and for a remote SSH session, whose local pane hosts the remote tmux client and always reports 0 while `full=1` reads the remote history), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the notice decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The one place `alternate_on` decides is a shell's AUTOMATIC load (`alt=recording`, see Full-scrollback replay): there the question is whether the capture holds the scrollback a nested tmux client keeps in its byte recording, not whether a pull can return more. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`.
|
||||
|
||||
**A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite).
|
||||
|
||||
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass without `alt=recording` (every `useFullHistory` pass; only shell loads send it) is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
|
||||
|
||||
⚠️ **A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery must verify itself** (`_scheduleDroppedOutputRecovery`, app.js): a hole in a TUI byte stream is a desynced cursor, which is muffled text (#464). It was a fire-and-forget 2s timer that nulled its own handle and then called `_onSessionNeedsRefresh()` — whose early returns (a buffer load in flight, a refresh already owning the session) are MOST likely to be true during exactly the burst that caused the drop, so the recovery was lost silently and the bytes were never replayed. `_onSessionNeedsRefresh` now returns whether it actually repainted, and the scheduler re-arms while it has not, bounded by `DROP_RECOVERY_MAX_ATTEMPTS` because the early returns it retries past are transient contention. A refresh that died at the capture fetch DEADLINE (it returns `'deadline'`) is a stalled link, not contention, and is NOT retried: each retry would be another `?full=1` capture waiting out a deadline of up to two minutes. The same debounce still collapses a burst into one attempt, and the `TERMINAL DROP` crash-trail line sits behind it, since one line per dropped frame evicted the whole 50-entry trail in under a second. Tests: `test/dropped-output-recovery.test.ts`, whose retry case and no-retry case only pin the fix as a pair.
|
||||
|
||||
@@ -225,7 +229,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, Line truncated
|
||||
|
||||
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
||||
|
||||
@@ -312,6 +316,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||
- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last.
|
||||
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
|
||||
- **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it.
|
||||
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
|
||||
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard).
|
||||
|
||||
@@ -365,15 +370,19 @@ The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings →
|
||||
|
||||
A file path an agent prints is a link on both surfaces it can appear on, and clicking it opens the file-preview overlay. Three things make that work and each has bitten:
|
||||
|
||||
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both build a fresh instance from it. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
|
||||
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
|
||||
|
||||
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. `test/response-viewer-file-links.test.ts` pins both properties.
|
||||
|
||||
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
|
||||
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow); nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
|
||||
**Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so.
|
||||
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
|
||||
|
||||
**Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`.
|
||||
|
||||
**Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. ⚠️ **The one place HTML renders is `/html-view/<cap>/...`** (`html-view-routes.ts`, `html-view-capabilities.ts`), never these routes: `POST /api/sessions/:id/html-view` mints a capability for the HTML file's own directory, and every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin`, so the page is opaque-origin even as a top-level tab and cannot read Codeman or drive its API. ⚠️ **Network access is open, by the owner's decision (2026-10-13, #547):** `HTML_VIEW_CSP` is the `sandbox` directive alone, which REPLACES Codeman's own CSP, with no fetch directive, so a page loads scripts, styles, fonts, images and media from anywhere and may fetch, XHR, open WebSockets, send beacons and post forms anywhere, like a file opened from disk. It can read its own folder through the capability and nothing else on this server; it cannot use Codeman's origin, cookies or API, or reach other folders. Never add a fetch directive or `allow-same-origin` without the owner; the exact header is pinned in `test/routes/html-view-routes.test.ts`. The directory is served recursively to the page's scripts (non-dot, asset-allowlisted, realpath-confined, blocklist applied, GET/HEAD only; the dot and extension rules run on the requested name AND on the symlink-resolved target, or a `cfg.json -> .git/config` link would serve the git config), which is why minting is refused for an HTML file in a hidden directory (for a file inside the workspace, a hidden directory below the workspace root, so a workspace that itself lives under one still renders) or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR` (named literally, since macOS sets `TMPDIR` in every terminal), the cases and user-space roots, a drive's mount point such as WSL's `/mnt/d`) or in any directory above one of them (`/home`, `/Users`, macOS's `/private`; `isBroadHtmlViewRoot`). A capability minted for a folder outside the workspace records that, and the serving route refuses it per request once `attachmentConfineToWorkspace` is on, the same re-check the attachment serve path makes. The preview renders it by default; the **Page** pill (`htmlRendered`, per-device like `mdRendered`) and any refused mint fall through to the source view with its Edit button. Tests: `test/routes/html-view-routes.test.ts`, the html-view block in `test/webview-auth-exemption.test.ts`, `test/webview-capability-revocation.test.ts`. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so.
|
||||
|
||||
**Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes.
|
||||
|
||||
@@ -381,6 +390,8 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk,Line truncated
|
||||
|
||||
⚠️ **ExcelJS follows a worksheet's comment, comment-note (VML), table and drawing links UNCHECKED, so the worker repairs its parsed model before it links it.** ExcelJS 4.4.0's `reconcile` looks each relationship `Target` up verbatim in maps keyed by its own spelling (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`, `drawing1`) and dereferences the result. openpyxl, which most agents write spreadsheets with, uses absolute Targets, its own part names (`/xl/comments/comment1.xml`, which ExcelJS never even loads) and a drawing XML ExcelJS parses to nothing, so a workbook with ONE comment, table or chart threw (`reading 'comments'`, `'name'`, `'anchors'`) and the whole preview failed. Right after `new ExcelJS.Workbook()` the worker wraps `workbook.xlsx.reconcile` (which `load()` calls once on the parsed model) to run `repairExcelJsModel()` (core) first: it re-points a link to the key ExcelJS stored for the same part (openpyxl's `/xl/tables/table1.xml` becomes `../tables/table1.xml`, so the table is kept), and drops a link, `<tablePart>`, `<drawing>` or `<hyperlink>` reference, or parsed-to-nothing drawing/table that would dereference nothing. It never adds anything, looks keys up as own properties only (a `__proto__` Target resolves to nothing), and the preview shows none of these parts. The wrap throws if a future ExcelJS renames `reconcile`. Real openpyxl 3.1.5 fixtures (`test/fixtures/xlsx/openpyxl-{comment,table,chart}.xlsx`) fail the worker tests without it.
|
||||
|
||||
### Filesystem path picker
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||
@@ -416,7 +427,7 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write
|
||||
- ⚠️ **`data-i18n-skip` on the container.** The translator's MutationObserver translates inserted headings and paragraphs, and the `.file-preview-content` entry in its skip list matches nothing (no element has that class), so the attribute is what keeps a Chinese UI from rewriting a README.
|
||||
- ⚠️ **Toggles are per-device, in their own localStorage keys** (`codeman:filePreviewMdRendered` / `LineNumbers` / `Wrap`), for the same reason as the Files panel's show-hidden toggle: the app-settings object is rebuilt from the settings modal on every save, and they are display state, not synced settings (`SettingsUpdateSchema` is `.strict()`). MD re-renders from the kept source (`filePreviewContent`, which is also what Copy copies) without a refetch; Lines/Wrap are class flips on the one `<pre>`, whose rules are scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's own code blocks. Lines are one inline `<span class="fp-line">` per line joined by real newlines, the counter in `::before` with `user-select: none`, so select and copy return the exact text.
|
||||
- ⚠️ **Caps.** Markdown fetches `lines=10000` (the route's `MAX_LINES_LIMIT`), because a rendered document cut at 500 lines reads as the whole document; other text keeps 500, which is what stops a huge log locking the tab in one `<pre>`. The attachment (out-of-workspace) branch keeps its 512 KB Range read and skips the 500-line clip for markdown. Edit mode is unchanged and still re-fetches `edit=1`; the three toggles hide while editing and for images, media and PDFs.
|
||||
- ⚠️ **`md` is NOT in `FILE_PREVIEW_EXTENSIONS`.** Only an in-workspace `.md` path clicked in the terminal still opens the tail viewer (see File-path links above: in-workspace text keeps live follow, which is what Ralph's `fix_plan.md` needs). Everything else reaches `openFilePreview()` and renders it: the Files panel, a path clicked in the Response Viewer (its delegate calls `openFilePreview()` directly), an out-of-workspace terminal path, and attachment cards. `avif` and `ico` were added to the set (a printed `favicon.ico` used to tail binary noise), with `avif` also in file-content's image set and file-raw's MIME map; out-of-workspace avif/ico stay unregistrable, like svg/bmp.
|
||||
- ⚠️ **`md` and `markdown` ARE in `FILE_PREVIEW_EXTENSIONS`** (owner decision 2026-10-13). Until then an in-workspace `.md` path clicked in the terminal opened the tail viewer, which showed "streaming" over raw text instead of the document (the live follow it gave Ralph's `fix_plan.md` was not worth that). Now every surface reaches `openFilePreview()` and renders it: the terminal (the main pane, grid tiles and the split's Pane B share `registerFilePathLinkProvider`, and a touch tap activates the same provider), the Files panel, a path clicked in the Response Viewer (its delegate calls `openFilePreview()` directly), and attachment cards. `FILE_PATH_LINK_PATTERN` links `.markdown` (and `.htm`) as well, or such a path would not be clickable at all. Other in-workspace text (`.log`, `.txt`, `.json`, code) keeps the tail viewer. `avif` and `ico` were added to the set (a printed `favicon.ico` used to tail binary noise), with `avif` also in file-content's image set and file-raw's MIME map; out-of-workspace avif/ico stay unregistrable, like svg/bmp.
|
||||
|
||||
Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above), `test/routes/file-routes.test.ts` (avif).
|
||||
|
||||
@@ -437,6 +448,20 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a
|
||||
|
||||
Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`.
|
||||
|
||||
### Git worktrees of a case
|
||||
|
||||
**Git worktrees** (case picker → **Git worktrees…**, the phone case sheet's **Worktrees**, Close Session's **Kill & remove worktree**): a LOCAL case whose folder is the top of a git repository keeps worktrees at `<repo>/.claude/worktrees/<name>` on a new branch (default `worktree-<name>`), so parallel agents each get their own checkout instead of sharing the case folder. That is Claude Code's own `--worktree` layout (measured in 2.1.296: same path, branch `worktree-<name>`, names of `[A-Za-z0-9._-]`, at most 64), and Codeman's names are a strict subset of Claude's. Core in `src/git-worktrees.ts` (pure name/branch gates, `worktree list --porcelain` parsing, exclude text; IO behind an injectable runner); routes in `routes/worktree-routes.ts` (`GET`/`POST /api/cases/:name/worktrees`, `DELETE /api/cases/:name/worktrees/:worktree`).
|
||||
|
||||
- ⚠️ **Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed.** Every component is `lstat`ed (a symlinked `.claude`, `worktrees` or worktree folder is refused, never followed) and the real path must equal the lexical one, so a repository cannot redirect a create or remove outside itself; removal also requires git to list the folder as a worktree of THIS repository. A worktree anywhere else is listed read-only (`managed: false`). A managed worktree whose folder is GONE stays removable (`resolveManagedWorktree({ allowMissing })`, removal only, never a session start: git lists it at exactly the managed path, no component on the way is a symlink, and `git worktree remove` then only drops git's record), since otherwise its name could never be used again.
|
||||
- ⚠️ **git never runs for a case at or inside a Docker case workspace** (or whose common git dir is there), and Docker and remote cases are refused with a 400 before any git, the same rule as the git status indicator: `git worktree add` runs the repository's post-checkout hook and checkout filters ON THE HOST. That is acceptable for a local case, where the user's agent already runs code.
|
||||
- ⚠️ **Removal is `git worktree remove` WITHOUT `--force`**, refused (409) while any live session (any owner) runs at or inside the worktree, and git's own refusal (modified or untracked files, a lock) is the answer, shown through `textContent`. The optional branch delete is `git branch -d`, never `-D`: an unmerged branch survives and the response says why (`branchError`).
|
||||
- ⚠️ **The main checkout must not see the worktrees.** When `git check-ignore` says they are not ignored, `/.claude/worktrees/` is appended to the repository's LOCAL `<common git dir>/info/exclude` (never a tracked `.gitignore`), and so is `/.claude/settings.local.json`: Codeman writes its hooks there for every claude session, and an untracked copy in a worktree would make `git worktree remove` refuse forever (Claude Code covers that file with the global excludes file, which a machine may not have). The agent skill copy (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) gets a line too, for every skills folder the registry names (`capabilities.agentSkillDir`, passed in by the route as data). A symlinked `info` dir or `exclude` file is reported, never written through.
|
||||
- **Names are gated before git**: charset and length (Zod and `worktreeNameError()`), then `git check-ref-format --branch`, whose output must EQUAL the input (`--branch` expands `@{-1}`, which the charset gate already refuses). The base is a resolved commit (`rev-parse --verify <base>^{commit}`), passed after `--` with `--no-track`; an existing branch is refused (`-b`, never `-B`, which would reset it). Create and remove are serialized per repository.
|
||||
- **Sessions start through the EXISTING create paths**, never a new spawn path: `POST /api/quick-start` with `worktree` (resolved with the same guards; hooks, the agent skill, the model override and the session all target the worktree, and a case that does not exist is a 404, never a scaffold), or `POST /api/sessions` with the worktree as `workingDir` (what the Run button uses for claude and shell). The frontend launches through `run({ worktree })` with a temporary `_runMode` swap, tabs named `w<n>-<worktree>`.
|
||||
- The unified session list names a live session's worktree from its folder layout (`worktreeFromPath`), so a codex or shell session gets the worktree pill before (or without) a transcript record.
|
||||
|
||||
Tests: `test/git-worktrees.test.ts` (pure half, then REAL git: create, exclude, symlink guards, dirty/live/locked refusals, `branch -d`), `test/routes/worktree-routes.test.ts` (real repository through the routes and quick-start, Docker/remote/ownership refusals), `test/worktree-ui.test.ts`, `test/worktree-modal-i18n.test.ts`.
|
||||
|
||||
### Ultracode and workflow-run visualization
|
||||
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
@@ -658,7 +683,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
||||
|
||||
### 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` always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416).
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` (so a rendered page's own `<video>`/`<audio>` can seek) always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416).
|
||||
|
||||
⚠️ **The size cap on all three is a sanity bound, not memory protection** (`MAX_FILE_DOWNLOAD_BYTES` in `config/buffer-limits.ts`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited): the bodies stream, so size costs a read stream and not RSS (measured: a 600MB download moved peak RSS by ~37MB). Its predecessor was a hardcoded 50MB whose comment still said "prevent memory exhaustion" long after the `readFile()` it described was replaced by `sendFileBody()`, so all it did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file, and now shares `sendFileBody()` with the other two.
|
||||
|
||||
@@ -823,7 +848,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
||||
|
||||
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
|
||||
|
||||
⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `tail=` for a shell; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell history pull) also sends `lines=<TILE_SCROLLBACK + rows>`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame.
|
||||
⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `SHELL_LOAD_BASE_QUERY` for a shell, the shell load without its fixed `lines=`; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell load, a shell history pull) also sends `lines=<TILE_SCROLLBACK + rows>`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none for a TUI and `SHELL_LOAD_QUERY`'s `lines=10000` for a shell), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame.
|
||||
|
||||
⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE it tears the tile down (like `wasActive`) and focuses it with `auto` before the delete request goes out; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead.
|
||||
|
||||
@@ -841,13 +866,15 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
||||
|
||||
⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
|
||||
|
||||
⚠️ **Detach Tiles** (`tileDetachEnabled`, per-device, default OFF: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; App Settings → Appearance, its row shown on desktop only). A tile leaves the grid for a window of its own, and comes back into any Codeman window's grid, by dragging, with nothing new on the server: a pop-out is another client of the same session (`detachSession`), and a tile is a `TerminalTile` in whichever window has it. (1) A header let go OUTSIDE the window opens it with `detachSession(id, placement)`, the tile's size (at least 480x320), its header near the drop point, after `TILE_TEAR_OFF_SETTLE_MS` (120 ms) that a claim from another window cancels. "Outside" is the dragend's own point off the viewport with `dropEffect: 'none'`; where a browser reports no point (0, 0) it is the page's record of a `dragleave` at the viewport's edge with nothing entered, cleared by any later `dragover`. A drop that any target took (`dropEffect: 'move'`, in this window or another) never opens one, a drop inside on nothing changes nothing, and with the setting on a lone or zoomed tile drags too (out only: no target here takes it). (2) A header dropped on ANOTHER Codeman window's tile or empty cell moves it there: that window's `_acceptTabDrops` takes a drag that is none of its own and carries `application/x-codeman-tile` (`_isForeignTileDrag`; the id is only readable on drop), docks it through the target's own drop (`_dockForeignTile` → `dropSessionOnTile` / `dropSessionOnSlot`, so `_reorderTiles` stays the one move path) and posts `tile-adopted` on the `codeman-windows` channel. Only the window the tile was dragged FROM acts on that (its drag still settling, or ended within `TILE_ADOPT_WINDOW_MS`): the tile goes with `focus: false`, and a last tile closes the grid onto the welcome screen, never onto that session, since two windows sizing one PTY garble it. (3) A pop-out's title (`#soloSessionTitle`, `_installSoloTileHandle`) carries the same type, so it drags back onto any grid: docking a popped-out session `_redock`s it first (a detached session never mounts), posts `close-request`, and ignores that pop-out's `detached` answers for `TILE_DOCK_GRACE_MS` (`_tileDockedRecently`), which would otherwise take the new tile straight back off. A pop-out that gets `close-request` is RELEASED (`_releaseSoloWindow`): it posts `redocked` at once, stops answering roll-calls, then closes; one a script may not close (opened by typing its URL) says where the session went instead of staying a pop-out every dashboard would mark detached again. A tile's ⋯ menu offers "Open in a new window" with the setting on (`detachTile`, placed over the tile): the keyboard's way to the drag. ⚠️ Chrome keeps a script-opened window on the opener's screen unless the page holds the Window Management permission, so a drop on another monitor opens at this screen's edge (drag the window across from there), and Wayland ignores the position. The gesture overlay reaches all of it through `tileAtPoint` / `canGrabTile` / `tileDropTargetAt` / `dropOnTileTarget` / `isOverTileGrid` / `detachTileAtPoint`, never move logic of its own; its pop-out has no click behind it, so a popup blocker refuses it unless the site's pop-ups are allowed (detachSession's toast says so, and the tile stays). ⚠️ Known limits: (a) every Codeman window in one browser shares ONE stored grid (`codeman:tile-grid`, localStorage), so reloading the window a tile left can restore the other window's grid (the window that loses a tile to another holds `_tilePersistHold` and never writes over it, but the next change in either window wins); (b) a drag between two browser PROFILES docks the tile, but neither its pop-out nor the source tile hears about it, since the `codeman-windows` BroadcastChannel is per profile; (c) a drag-out held longer than the browser's user-activation window (about 5 s in Chrome and Firefox, about 1 s in Safari) gets "Pop-out blocked" and the tile stays. Tests: `test/tile-grid-detach.test.ts`.
|
||||
|
||||
### Gesture control: the setting
|
||||
|
||||
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Terminal & Input → Scrolling & rendering (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
|
||||
|
||||
### Gesture control: the source package
|
||||
|
||||
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
|
||||
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`. The tile grid's verbs (a tile pinched anywhere on it and carried onto another tile or an empty cell; a tab carried onto the grid joins it there; with Detach Tiles a tile let go outside the grid pops out) ask tile-grid.js's gesture helpers (see Tile grid, Detach Tiles) and never move tiles themselves, so the hand and the mouse cannot disagree about a drop.
|
||||
|
||||
### Theme skins
|
||||
|
||||
@@ -1059,7 +1086,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
### Z-index layers
|
||||
|
||||
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
|
||||
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, `#folderGridOverlay` — above the response viewer a folder link opens it from, below the file preview its tiles open, so a tile's preview and its ←/→ paging sit on top of the grid), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
|
||||
|
||||
## Security layers
|
||||
|
||||
@@ -1083,9 +1110,9 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
### Buffers, uploads, and terminal history
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`.
|
||||
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`.
|
||||
|
||||
### Process-tree walks are bounded
|
||||
|
||||
|
||||
@@ -168,6 +168,10 @@ automatic per-case injection on session create, into the directory each CLI scan
|
||||
skills). The skill carries the guard, the safety rules, and verified
|
||||
wait/orchestration recipes.
|
||||
|
||||
If the caller speaks the Model Context Protocol (Claude Desktop, Cursor, Codex, any MCP
|
||||
client), point it at `codeman mcp` instead: seven tools over these same endpoints,
|
||||
configured with `CODEMAN_API_URL`. See the README section "MCP server".
|
||||
|
||||
The common ones:
|
||||
|
||||
```bash
|
||||
|
||||
|
Before Width: | Height: | Size: 859 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 7.1 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,68 @@
|
||||
# Media assets: videos and GIFs
|
||||
|
||||
Codeman produces a lot of video: README GIFs, website loops, release-note clips, promo videos and their drafts, raw screen captures. This page says where each kind lives and which of them belong in git.
|
||||
|
||||
## The rule
|
||||
|
||||
**Only media that a public page embeds is pushed to GitHub. Everything else stays on the maintainer's machine, in the local media library.**
|
||||
|
||||
| Kind | Where it lives | On GitHub? |
|
||||
| --- | --- | --- |
|
||||
| GIFs the README, the wiki or a published article embeds | `docs/images/` on `master` | yes |
|
||||
| Release-note and media-kit clips | the orphan `design-assets` branch | yes, but never on `master` |
|
||||
| getcodeman.com videos and GIFs | `pr/website/assets/` (local, deployed to Cloudflare Pages) | no |
|
||||
| Masters, promo videos, drafts, shorts, feature clips, raw captures | `pr/media/` (the local media library) | no |
|
||||
| Remotion render inputs for the promo videos | `scripts/remotion/public/` on the unpushed promo branch | no |
|
||||
|
||||
`.gitignore` enforces it: `*.mp4`, `*.webm`, `*.mov`, `*.m4v`, `*.mkv`, `*.avi` and `*.gif` are ignored everywhere except `docs/images/*.gif`, plus `scripts/remotion/public/`, whose render inputs the promo branch tracks. A video that really must be committed needs `git add -f`, which makes the exception visible in review.
|
||||
|
||||
A GIF that leaves `docs/images/` stays in git history, so removing it does not shrink a clone. The rule keeps new weight out and the tree honest about what is actually used.
|
||||
|
||||
## Tracked GIFs (`docs/images/`)
|
||||
|
||||
| File | Embedded in | Shows |
|
||||
| --- | --- | --- |
|
||||
| `tiles-crt-stats-1600-20261010.gif` | README hero (en + zh-CN) | six live agents in the tile grid, powering on and off with the CRT animation, header stats on |
|
||||
| `tile-grid-20261009.gif` | README, Tile Grid section | the Tiles button opening six live sessions side by side, and closing them |
|
||||
| `zerolag-demo-20260728.gif` | README, wiki `Input-And-Voice`, `packages/xterm-zerolag-input/README.md`, CHANGELOG, getcodeman.com docs | instant local echo next to slow server echo, on two phones |
|
||||
| `codeman-tab-states-20261010.gif` | README, Tab Alerts section | annotated tab states: working, blocked on a question, idle |
|
||||
| `codeman-skill-crt-20261010.gif` | README, agent skill section | one prompt to Claude Code spawning and driving two worker sessions through the skill |
|
||||
| `subagent-demo-20260724.gif` | the "mission control" article on dev.to, Hashnode and Medium (hotlinked from `master`) | subagent windows fanning out |
|
||||
|
||||
Before removing one, search for hotlinks: `README*.md`, `docs/wiki/`, `packages/*/README.md`, `CHANGELOG.md`, the site (`pr/website/`), and published articles, which link `raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/<file>` and break silently.
|
||||
|
||||
On 2026-10-13, seven GIFs that no page embedded left `docs/images/` and moved into the library: `codeman-demo.gif`, `codeman-skill-20261010.gif`, `subagent-demo-new.gif`, `subagent-demo-opt.gif`, `tab-alerts-glow-20260815.gif`, `tiles-crt-20261010.gif`, `tiles-crt-stats-20261010.gif`.
|
||||
|
||||
### Adding or replacing a README GIF
|
||||
|
||||
1. Keep the master (the lossless MP4 or the full-size GIF) in `pr/media/clips/<feature>/`, or in the project folder it came from.
|
||||
2. Commit only the size the README shows, named `<feature>-<YYYYMMDD>.gif`, and keep it lean: the current ones are 0.8 to 7 MB.
|
||||
3. A replaced GIF leaves `docs/images/` in the same commit, unless something outside the repo hotlinks it (see above).
|
||||
|
||||
## Website media (getcodeman.com)
|
||||
|
||||
The site is built and deployed from `pr/website/` (gitignored) with `pr/deploy-site.sh`. Its videos and GIFs sit in `pr/website/assets/` because that directory is what gets uploaded: they are web cuts (1280 to 1600 px wide, high CRF, posters next to them) derived from masters in the library. `pr/site-notes.md` records how each one was cut and from which master. A few superseded files stay deployed on purpose, so links that point at them keep resolving.
|
||||
|
||||
## Release notes and the media kit (`design-assets` branch)
|
||||
|
||||
GitHub releases and the Announcements discussions embed clips from the orphan `design-assets` branch (`release-1.40/`, `release-1.41/`, `media-kit-2026-10/`). They are referenced by a commit-pinned raw URL, so the branch can change later without breaking old notes, and none of it ever touches `master`.
|
||||
|
||||
## The local media library (`pr/media/`)
|
||||
|
||||
`pr/` is gitignored, so the library is never pushed. It holds every master, every finished promo cut and every superseded draft in one tree:
|
||||
|
||||
```
|
||||
pr/media/
|
||||
README.md curated guide: what each collection is, which cut is current, where it is published
|
||||
INVENTORY.md generated list of every file (size, length, resolution, fps, date, published-or-not)
|
||||
inventory.mjs regenerates INVENTORY.md: node pr/media/inventory.mjs
|
||||
videos/<production>/ finished promo videos; superseded renders in drafts/
|
||||
shorts/<episode>/ per-feature cuts of a promo; older sets in drafts/
|
||||
clips/<feature>/ feature loops and GIF masters (README / site / release clips start here)
|
||||
crt-gifs/ CRT tile-animation loops
|
||||
files-one-click-20261011/ the Files panel loop project (own README)
|
||||
screenshots-hq/ high-res stills and their capture masters
|
||||
raw-captures/ unedited screen recordings, safe to prune
|
||||
```
|
||||
|
||||
Two working directories keep files the library only archives, because tools read them from fixed paths: `pr/website/assets/` (the deploy upload) and the promo worktree's `scripts/remotion/public/` (Remotion's `staticFile()` inputs). Renders still land in `scripts/remotion/out/` and are filed into `pr/media/videos/<production>/` once checked. A finished render never goes to `tmp/`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Releasing Codeman (the COM procedure)
|
||||
|
||||
> Moved from the root `CLAUDE.md`, which keeps a pointer here. What SemVer covers is defined in `docs/versioning-policy.md`. The root file's Session Safety rules (branch check, explicit paths, never `git add -A`) apply to every step.
|
||||
|
||||
When user says "COM":
|
||||
|
||||
1. **Determine bump type**: `COM` = patch (default), `COM minor` = minor, `COM major` = major
|
||||
2. **Create a changeset file** (no interactive prompts). Write a `.md` file in `.changeset/` with a random filename:
|
||||
|
||||
```bash
|
||||
cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET'
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
|
||||
CHANGESET
|
||||
```
|
||||
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
|
||||
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
|
||||
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
8. **Publish the new version to the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. Maintainer only (it needs that GitHub account); contributor clones skip it and say so.
|
||||
|
||||
9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
@@ -251,6 +251,71 @@ unreachable host answers "unknown", which also means do not revive. The answer
|
||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||
|
||||
## Terminal history from the remote tmux
|
||||
|
||||
The local pane runs the ssh attach, so it only sees the remote tmux client's
|
||||
full-screen repaints and keeps a fraction of the scrollback (none after a
|
||||
reattach). A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a
|
||||
remote session therefore reads the scrollback rows from the REMOTE pane in one
|
||||
ssh round trip (`src/remote-pane-history.ts`) and splices them above the local
|
||||
visible frame and caret. Bounded (5 s for the round trip, at most 1.5 s queued
|
||||
for a slot in the shared ssh limiter) and fail-soft: any failure keeps the
|
||||
local capture, and a transport failure (ssh exit 255, a timeout, output that is
|
||||
not the script's) also skips that host for 30 s; a failure of the remote
|
||||
command itself (a remote tmux session that is gone) affects that session only.
|
||||
How much history exists is the remote tmux's own `history-limit`, which the
|
||||
launch command does not set (tmux's default is 2000 lines).
|
||||
|
||||
The same round trip also reads the remote SCREEN, in the same tmux command list
|
||||
as the history, because two seams would otherwise drop rows at the join:
|
||||
|
||||
- **Rows that scroll during the round trip.** The local frame is captured only
|
||||
once the round trip is back, so rows that scrolled off the screen in between
|
||||
are in neither the remote history nor the local frame (measured before this
|
||||
read existed: 13 lines lost with a 150 ms round trip and fast output).
|
||||
- **A shared remote session with a taller client.** Under `window-size latest`
|
||||
a remote session attached elsewhere is sized by its latest client; when that
|
||||
client is taller than this pane, the rows above this pane's frame are only
|
||||
on the remote screen (measured before: 20 rows).
|
||||
|
||||
Both are rows of the remote screen above where the local frame begins, so the
|
||||
load keeps them between the history and the frame (`joinScrollbackAndFrame`,
|
||||
`findFrameInScreen` in `src/tmux-manager.ts`). The frame is placed by matching
|
||||
its top rows against the screen as text (escape sequences and trailing blanks
|
||||
ignored, since the two tmuxes need not encode attributes alike); the screen's
|
||||
trailing blank rows and its last content row, which output in flight can still
|
||||
change, are left out of the match, the overlap must be at least three rows
|
||||
with two of them non-blank, and the highest match wins. A placement too low
|
||||
would REPEAT the rows between it and the true one, so a shorter overlap (a lone
|
||||
`$` row that happens to match after a spinner row changed) is not trusted.
|
||||
When the frame cannot be placed (more output scrolled than the screen held, or
|
||||
nothing matches), the join is history over frame, as without the screen read.
|
||||
That includes an agent TUI mid-turn (Claude Code, codex, opencode): it keeps a
|
||||
live region (spinner, composer, footer) pinned to the bottom and inserts new
|
||||
rows above it, so the frame is not a shifted copy of the screen and is not
|
||||
placed. A plain shell places, unless the frame's top row is the second half
|
||||
of a wrapped line (the screen holds that line as one joined row, so nothing
|
||||
matches and the join is history over frame). What is left: when the screen
|
||||
repeats a whole block of rows, the frame can be placed at the higher copy,
|
||||
which leaves out the rows between the two copies.
|
||||
|
||||
The history, the history size and the screen are ONE tmux command list
|
||||
(`capture-pane … \; display-message … \; capture-pane …`), not separate `tmux`
|
||||
runs. tmux runs a client's command list back to back without reading pane
|
||||
output in between, so the screen begins exactly where the history ends. As
|
||||
separate runs the shell kept writing between them and the rows that scrolled in
|
||||
those milliseconds were in neither capture (measured with output streaming: 1-2
|
||||
rows per load on a LAN host, every read on a local busy pane). This rests on how
|
||||
tmux's command queue behaves, not on a documented guarantee. A random marker row
|
||||
(`display-message`) separates the history from the size and the screen, since
|
||||
tmux output cannot carry the NUL markers around it. tmux before 3.2 prints no
|
||||
newline after `capture-pane -p` output, so when the history ends inside a
|
||||
wrapped line the marker is glued onto that row; the parser accepts the marker
|
||||
at the end of a row and re-terminates the history. The remote `tail -c` bound
|
||||
applies to that whole output, history first, and is the configured byte cap
|
||||
plus 1 MiB for the marker, size and screen, so a cut lands in the history; the
|
||||
route trims what it returns to the cap itself.
|
||||
|
||||
## File access over SSH
|
||||
|
||||
A remote case's `workingDir` is an absolute path on the **remote** host
|
||||
@@ -270,6 +335,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr
|
||||
| `GET /api/sessions/:id/file-thumbnail` | `400` for remote files |
|
||||
| `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there |
|
||||
| `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` |
|
||||
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
|
||||
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
|
||||
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
|
||||
|
||||
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
|
||||
|
||||
@@ -374,6 +374,23 @@ Two registration paths, with **different trust**:
|
||||
file outside the session's own workspace. Cross‑workspace attach must go through
|
||||
the explicit POST path above.
|
||||
|
||||
**Folder grid grants** (`POST /api/sessions/:id/folders`, `src/folder-listing.ts`)
|
||||
follow the explicit-POST trust level: a deliberate, Origin‑guarded request, allowed
|
||||
cross‑workspace subject to the same guard (blocklist on the folder AND on every
|
||||
listed file, `confineToWorkspace` when set, hidden entries and unsupported types
|
||||
left out). The grant is an id for a resolved folder; files are served by
|
||||
`folderId` + a bare file name only (no `/`, `..` or NUL, supported type), and each
|
||||
request re‑runs `resolveServableAttachmentPath` on the joined path, so a file that
|
||||
was swapped for a symlink to a blocked target after listing is still refused.
|
||||
The file a symlink resolves to is held to the attachment gate as well (supported
|
||||
extension, regular file), so a supported-looking name cannot serve an arbitrary
|
||||
file, and a FIFO or device node is never opened.
|
||||
In multi‑user mode a regular user is held to the filesystem path picker's rule
|
||||
(`folderAllowedForUser`): the folder, and again each served file after symlink
|
||||
resolution, must sit inside their own `userSpacePath` or the session's
|
||||
`workingDir`, else 403. User spaces live inside `homedir()`, so without it one user
|
||||
could list and read another's space.
|
||||
|
||||
### SSE log‑tail route — intentional extra read roots
|
||||
|
||||
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log
|
||||
@@ -529,6 +546,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **The lost‑frame recovery page is the third unauthenticated 200, and the only one decided by request headers alone.** The proxy's runtime shim masks `/webview/<cap>/` off the page's own URL so a single‑page app routes on the path it expects; a navigation the page then starts itself (`location.reload()`, a root‑absolute `location.href`) lands on Codeman's root with no capability anywhere, no cookie (opaque origin) and a Referer naming the masked page. `serveLostWebviewFrame()` in `middleware/auth.ts` recognises it by shape (`GET`/`HEAD`, `Sec-Fetch-Dest: iframe` or `frame`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` or absent) and answers, BEFORE the credential checks and without counting an auth failure, with a static page whose only content is a `postMessage` of the lost path to the parent tab (`default-src 'none'` plus the hash of that one script, `no-store`, `referrer: no-referrer`, no reflected input). It is fenced to paths that are NOT registered routes and never `/api/`, `/ws/` or `/q/`, with one carve‑out: `/` itself, because the landing page masks to exactly `/` and its reload otherwise rendered Codeman's app shell inside the web tab. `/` is admitted only when the request carries neither the `codeman_session` cookie nor an `Authorization` header: nothing in Codeman frames its own root and a sandboxed frame has neither, while a framed `/` that does carry credentials still gets the shell. On a passwordless install no auth hook runs, so the index route applies the same test itself (`isLostWebviewRootFrame`). ⚠️ Known property, accepted rather than mitigated: those headers are trivially set by a non‑browser client, so an unauthenticated caller can distinguish a registered route (401) from a non‑route (200) and enumerate the route table; the routes are public in `docs/api-reference.md`, so nothing is learned. Pinned by `test/webview-auth-exemption.test.ts` (password) and `test/webview-lost-root-frame.test.ts` (passwordless).
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Rendered HTML files (`/html-view/<capability>/`) are the other capability-gated route that skips cookie auth.** A clicked `.html` file renders through it so an agent's report can be viewed from any device. The exemption is narrower than the proxy's: `GET`/`HEAD` only (plus the page's CORS preflight, which `registerSecurityHeaders` answers itself with `Access-Control-Allow-Methods: GET, HEAD` and never hands to a route), the capability in the path only (no `Referer` form), and it is a separate store, so a web-tab capability never opens a page and vice versa. The capability is minted by an authenticated `POST /api/sessions/:id/html-view` (session ownership via `findSessionOrFail`, the attachment guard for out-of-workspace files), held in memory with a rolling TTL and revoked with the web-tab ones on logout, admin logout and user deletion. Every response carries `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page is opaque-origin even when opened as a top-level tab and cannot read Codeman's document, cookies or API. **Network access is open, by the owner's decision (2026-10-13):** that `sandbox` directive is the whole policy (it replaces Codeman's own `default-src 'self'` CSP and carries no fetch directive), so a rendered page behaves like a file opened from disk in a browser. What a page CAN do: load scripts, styles, fonts, images and media from any host (an agent's dashboard pulling Chart.js from a CDN), make fetch, XHR, WebSocket, beacon and form requests to any host, and read the files of its own folder through its capability. What it CANNOT do: act as Codeman's origin, read Codeman's cookies or document, use Codeman's API (a write arrives with `Origin: null` and the Origin guard refuses it; a read's response stays unreadable, because Codeman answers CORS only for its own localhost origins, and with a password set the request carries no session cookie either), or read any folder other than its own. The accepted consequence is that a page can send what it can read, its own folder, to any host, which is why the folder rules below are the boundary that matters. `Referrer-Policy: no-referrer` keeps the capability URL out of the `Referer` the page's third-party requests carry. Pinned by the exact-header test in `test/routes/html-view-routes.test.ts`. It may read its own directory tree (non-dot, asset-allowlisted, realpath-confined, blocklist applied; the dot and extension rules hold for the symlink-resolved target as well as the requested name), so minting is refused for an HTML file in a hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a directory above one (`/home`, `/Users`). Pinned by `test/routes/html-view-routes.test.ts` and the html-view block in `test/webview-auth-exemption.test.ts`.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
|
||||
|
||||
---
|
||||
|
||||
@@ -46,6 +46,8 @@ Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a con
|
||||
|
||||
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
|
||||
|
||||
Since Claude Code v2.1.251 the payload also carries a `prompt_cache` object for the main conversation: `warm`, `ttl` (`5m` or `1h`), `expires_at` (epoch seconds), `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at` and `last_miss_cause`. `StatusTelemetrySchema` lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer); `parsePromptCache()` normalizes only the subset Codeman stores. The footer appends `cache:until HH:MM` while warm or `cache:cold` otherwise, and Claude re-runs the statusline when `expires_at` passes, so the cold flip arrives without a turn. The Session Options readout adds the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (the first warm report whose `recache_tokens_if_cold` is null after one that carried it; `expected_rebuilds` rises only at the next request, so it is not read; never raised as an alert). The last report is restored on a Codeman restart and the readout is time-aware, so a value whose expiry has passed shows as cold rather than stale-warm.
|
||||
|
||||
### Alternatives considered & rejected
|
||||
|
||||
| Source | Why not |
|
||||
|
||||
@@ -46,7 +46,8 @@ case. Each has a stable id, and that id is what the API, the wait primitives, an
|
||||
event use.
|
||||
|
||||
Several sessions can share one case. That is the normal way to parallelize: three workers
|
||||
in the same repo, three tabs, one case.
|
||||
in the same repo, three tabs, one case. They then also share one folder; when the workers
|
||||
edit and commit in parallel, give each its own checkout with [Git Worktrees](Git-Worktrees).
|
||||
|
||||
A session carries state the case does not:
|
||||
|
||||
|
||||
@@ -98,6 +98,52 @@ codeman agent rm "$SID" # any session except t
|
||||
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
|
||||
worker exited, `4` refused. `--json` prints the response's `data`.
|
||||
|
||||
## The MCP server
|
||||
|
||||
`codeman mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server, so any MCP
|
||||
client (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor) can drive Codeman, from
|
||||
inside a Codeman session or from an assistant on your desktop. It is a client of the same
|
||||
HTTP API, with seven tools: `list_sessions`, `start_session`, `send_prompt`,
|
||||
`wait_for_session`, `read_output`, `interrupt_session` and `delete_session`.
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
claude mcp add codeman -e CODEMAN_API_URL=https://127.0.0.1:3000 -- codeman mcp
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"codeman": {
|
||||
"command": "codeman",
|
||||
"args": ["mcp"],
|
||||
"env": { "CODEMAN_API_URL": "https://127.0.0.1:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
# Codex (~/.codex/config.toml): raise the 60 s default tool timeout for long waits
|
||||
[mcp_servers.codeman]
|
||||
command = "codeman"
|
||||
args = ["mcp"]
|
||||
env = { CODEMAN_API_URL = "https://127.0.0.1:3000" }
|
||||
env_vars = ["CODEMAN_MUX", "CODEMAN_SESSION_ID"] # Codex passes MCP servers a minimal env; these let it know the Codeman session it runs in
|
||||
tool_timeout_sec = 120
|
||||
```
|
||||
|
||||
The JSON form is what Claude Desktop, Cursor and Gemini CLI take. Add `CODEMAN_PASSWORD` to
|
||||
`env` when the server has a password.
|
||||
|
||||
- **It never guesses a server.** Without `CODEMAN_API_URL` (or `--url`) every call says how
|
||||
to configure it.
|
||||
- **Only its own sessions.** Prompting, interrupting and deleting are limited to sessions it
|
||||
started; listing, reading and waiting work on everything. `codeman mcp
|
||||
--allow-any-session` lifts that, for an assistant that should drive your existing tabs.
|
||||
- **Waits are short by default** (45 s, at most 600 s), so they fit a client's tool
|
||||
timeout. A `timed_out` answer means call again.
|
||||
|
||||
## The manual path
|
||||
|
||||
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Git Worktrees
|
||||
|
||||
Sessions in one case share its folder. That is fine for one agent, but three agents in the
|
||||
same checkout edit the same files, overwrite each other's builds, and commit each other's
|
||||
half-finished work. A **git worktree** gives each of them a checkout of its own: same
|
||||
repository, same history, a separate folder on a separate branch.
|
||||
|
||||
Codeman creates worktrees for a case whose folder is a git repository, starts sessions in
|
||||
them, and removes them again when the work is done.
|
||||
|
||||
## Where they live
|
||||
|
||||
`<case>/.claude/worktrees/<name>`, on a new branch `worktree-<name>`. That is exactly where
|
||||
Claude Code's own `claude --worktree <name>` puts them, so a worktree made in Codeman is one
|
||||
Claude Code recognizes, and the other way round. Codeman's Recent Sessions and Session Manager
|
||||
lists mark a session that runs in one with a `⑂ <name>` pill.
|
||||
|
||||
The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/`
|
||||
(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the
|
||||
`codeman` folder it writes when **Codeman Agent Skill** is on) to the repository's local
|
||||
`.git/info/exclude` when git does not ignore them already. Without that, those files would make
|
||||
every worktree look modified and git would refuse to remove it. Your tracked `.gitignore` is
|
||||
never touched.
|
||||
|
||||
## Creating one and starting a session in it
|
||||
|
||||
1. Pick the case in the toolbar's case picker.
|
||||
2. Open the picker again and choose **Git worktrees…** (on a phone: the case sheet's
|
||||
**Worktrees** button).
|
||||
3. Under **New worktree**, type a name (letters, digits, `.`, `_`, `-`, up to 64 characters).
|
||||
4. Pick the CLI under **Start sessions with** (Claude Code, Codex, a shell, any installed run
|
||||
mode).
|
||||
5. **Create & start session**.
|
||||
|
||||
The worktree is created from the case's current commit, and the session opens in it as a tab
|
||||
named `w1-<name>`. Under **Branch and start point** you can name the branch yourself or start
|
||||
from another branch, tag or commit. An existing branch is never reused or reset: pick another
|
||||
name.
|
||||
|
||||
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
|
||||
|
||||
## Removing one
|
||||
|
||||
Two ways, and both use `git worktree remove` **without** `--force`:
|
||||
|
||||
- **Remove…** on the worktree's row in the dialog.
|
||||
- Closing a session that runs in a worktree: the Close Session dialog offers **Kill & remove
|
||||
worktree**, which kills the session and then removes its worktree.
|
||||
|
||||
git refuses to remove a worktree that still holds uncommitted or untracked files, and Codeman
|
||||
shows that refusal as git worded it and leaves everything in place. Commit, stash or delete the
|
||||
work first, then remove again. A worktree a live session still runs in is refused too.
|
||||
|
||||
**Also delete its branch** deletes the branch with `git branch -d`, which keeps a branch whose
|
||||
commits are not merged yet. The worktree is still removed, and the message says the branch was
|
||||
kept.
|
||||
|
||||
If a worktree's folder was deleted by hand, its row says **missing** and offers only
|
||||
**Remove…**: that clears the record git keeps of it, so the name can be used for a new worktree.
|
||||
|
||||
## What is not supported
|
||||
|
||||
- **Docker cases and remote (SSH) cases.** A container can write to its workspace, and creating
|
||||
a worktree runs the repository's hooks and checkout filters on the Codeman machine, so git is
|
||||
never run there. A remote case's repository is on another machine.
|
||||
- A case folder that is not the top of a repository (a subfolder of one), a bare repository, or
|
||||
an empty repository with no commit yet.
|
||||
- Worktrees outside `.claude/worktrees/` (made by hand with `git worktree add`) are listed, but
|
||||
Codeman neither starts sessions in them from the dialog nor removes them.
|
||||
|
||||
The HTTP endpoints are in the [HTTP API](HTTP-API) reference (`/api/cases/:name/worktrees`).
|
||||
@@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain:
|
||||
| ------------------- | -------- | --------------------------------------------------- |
|
||||
| System | 56 | Status, settings, digest, updates, tunnel. |
|
||||
| Sessions | 34 | Create, input, terminal, wait, last response, kill. |
|
||||
| Cases | 34 | Create, link, clone, remote and docker cases. |
|
||||
| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. |
|
||||
| Files | 17 | Preview, edit, raw, attachments, path picker. |
|
||||
| Orchestrator | 10 | Plans and phases. |
|
||||
| Ralph | 9 | Loop control and configuration. |
|
||||
|
||||
@@ -66,6 +66,7 @@ codeman web # then open http://localhost:3000
|
||||
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
||||
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
|
||||
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
||||
| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? |
|
||||
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
||||
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
|
||||
| [Keyboard Shortcuts](Keyboard-Shortcuts) | What can I drive from the keyboard? |
|
||||
|
||||
@@ -99,6 +99,14 @@ limit-paused. Without that, the next cycle would fire `/clear` and wipe the conv
|
||||
are waiting to resume. This is the single most useful setting for overnight runs on a
|
||||
subscription plan.
|
||||
|
||||
## The prompt cache readout
|
||||
|
||||
**Claude only.** On the Respawn tab, above the loop controls.
|
||||
|
||||
Claude Code bills a turn against the cached conversation while that cache is warm, and lets the cache expire after a fixed idle time: one hour on a subscription within plan usage, five minutes on an API key, a cloud provider or extra-usage credits. The first turn after a longer break re-processes the whole context at the cache-write rate, which is why coming back to a big session after lunch costs more than the turns around it.
|
||||
|
||||
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
|
||||
|
||||
## The plan usage chip
|
||||
|
||||
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
|
||||
|
||||
@@ -66,7 +66,7 @@ autocorrect: Enter adds a new line, and only Send delivers the text, as one past
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Image button attaches photos and puts their paths into the draft.
|
||||
is parked. The editor's Image button attaches photos or videos and puts their paths into the draft.
|
||||
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
|
||||
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
|
||||
editing the last queued message and walking the prompt stack.
|
||||
@@ -100,13 +100,37 @@ looks exactly like a dead button.
|
||||
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
|
||||
into the Run dropdown.
|
||||
|
||||
## The Attach button
|
||||
|
||||
The toolbar's **Attach** button (the picture icon beside the mic) is the keyboard-down way to
|
||||
hand the agent a photo or a video. One tap opens the camera and photo library sheet, nothing
|
||||
else: no editor, no keyboard. The chosen files upload and their paths land on the prompt, so
|
||||
you then tap the terminal, write the rest of your message and press Enter. Videos (mp4, webm,
|
||||
mov, m4v, ogv) upload as recorded, up to 1 GB each by default
|
||||
(`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the server changes it), so a long 4K clip takes a moment
|
||||
on the way up.
|
||||
|
||||
If the keyboard was up when you tapped Attach, iOS closes it to show the photo sheet and does
|
||||
not bring it back, so the tap on the terminal is how you get it again. That is the system's
|
||||
behaviour around its own picker rather than a Codeman setting.
|
||||
|
||||
The paths go onto the prompt as unsent text, exactly like something you typed, which means
|
||||
they travel with it. Tap **Compose** afterwards and the editor opens holding the paths *and*
|
||||
anything you had already typed, so you can write in front of a path, fix a word, and Send.
|
||||
**Use terminal keyboard** hands a single-line message back the other way, onto the prompt,
|
||||
so neither surface holds a half of your message. A multi-line message cannot make that trip
|
||||
(the prompt is a single line), so it stays parked as a draft and the dot on the Compose key
|
||||
shows it is waiting. The Compose editor's own Image button remains for the times you are
|
||||
already writing in the editor.
|
||||
|
||||
## Tapping, links and copying
|
||||
|
||||
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
|
||||
answer in the response viewer — it opens a tab rather than navigating the dashboard away,
|
||||
which on a phone would unload the whole session view.
|
||||
- **Tap a file path** an agent printed and the file-preview overlay opens; a log path opens the
|
||||
log viewer. Works in scrolled-up transcript too.
|
||||
- **Tap a file path** an agent printed and the file-preview overlay opens (Markdown rendered,
|
||||
HTML as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up
|
||||
transcript too.
|
||||
- A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's
|
||||
numbered choice still answers the dialog even when the row contains a path — the dialog wins,
|
||||
because on a phone it is the only interaction that matters.
|
||||
|
||||
@@ -102,6 +102,7 @@ every session or only the active tab.
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
| Spawned Tabs Follow Their Parent | A session started by another one (an agent's worker, a `parentSessionId` spawn) follows its parent's tab into its group and moves with it until you move it by hand; the row menu then says where it sits and offers *Follow parent again*. Off, it starts right after its parent and stays put. Decided when the tab is created, so flipping it leaves existing tabs alone. Synced, off by default. |
|
||||
| Overview Home Screen | The phone home screen. On by default. |
|
||||
|
||||
### Animations
|
||||
@@ -216,6 +217,8 @@ Some things are configured before the server starts, not in the UI:
|
||||
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
|
||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image. 50 MB by default. |
|
||||
| `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. |
|
||||
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
||||
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
|
||||
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
|
||||
|
||||
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
|
||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. **Focus:** *Add to Focus* in a row's ⋯ menu (right-click on a web tab) pins a shortcut to that tab in a **Focus** section at the top of the rail, with or without groups; the tab itself stays where it is. Click a shortcut to switch to its tab, right-click it (or press the menu key) for *Remove from Focus*. Focus follows you across devices; whether it is collapsed is kept per device. Desktop and tablet only. |
|
||||
|
||||
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
@@ -214,17 +214,26 @@ TUIs render correctly.
|
||||
Worth knowing:
|
||||
|
||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||
A fullscreen Claude tab keeps its history inside Claude, so switching back to it
|
||||
reloads only the current screen, which keeps tab switches fast.
|
||||
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
|
||||
history; press **Load full history** to pull the rest explicitly. Automatic output
|
||||
history; press **Load full history** to pull the rest explicitly. That notice only
|
||||
appears once you scroll to the top, leaves when you scroll back down, and stays away
|
||||
for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen
|
||||
Claude) never show it, since there is nothing more to load. Automatic output
|
||||
recovery stays within the bounded browser buffer.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
||||
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. OpenCode's wheel and swipes page its own conversation
|
||||
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
|
||||
too. Other CLIs scroll locally.
|
||||
(PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen
|
||||
and leave the terminal no history of its own (an older Codex that draws inline keeps
|
||||
its history in the terminal and scrolls locally). The first small movement of a
|
||||
gesture pages at once, then one page per half screen of travel; one finger on the
|
||||
screen is one gesture however slowly it moves. In a grid tile or the split view's
|
||||
second pane the wheel pages too. Other CLIs scroll locally.
|
||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||
`Ctrl+Shift+C` always copies.
|
||||
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
||||
|
||||
@@ -87,9 +87,10 @@ File paths in a session are links. That works in two places:
|
||||
render as underlined monospace links.
|
||||
|
||||
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
|
||||
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
|
||||
viewer instead, which follows a file that is still being written.
|
||||
working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a
|
||||
page. The exception is a plain text file (a log, `.txt`, `.json`, code) inside the workspace
|
||||
clicked in the terminal: that opens in the tail viewer instead, which follows a file that is
|
||||
still being written. A Markdown file always opens rendered, wherever you click it.
|
||||
|
||||
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
|
||||
@@ -101,8 +102,24 @@ Outside the workspace the allowlist is images, video, audio, PDF, Office documen
|
||||
files, where "text" is the same list the viewer will let you edit: code, config, logs, csv,
|
||||
markdown. The reasoning is that a session can already `cat` any of those, so the file suffix
|
||||
was never what kept anything secret; the path guard is. Types outside the list (`.svg`,
|
||||
`.bmp`) say so rather than failing silently, and `.html` previews as source rather than being
|
||||
rendered, so nothing served this way can execute in the page.
|
||||
`.bmp`) say so rather than failing silently.
|
||||
|
||||
An `.html` file opens as a rendered page by default, with its relative CSS, scripts, images and
|
||||
data files. The page is served from a separate `/html-view/...` address in a sandbox with no
|
||||
origin of its own, so its scripts can never read Codeman's page, cookies or API.
|
||||
|
||||
By the project owner's decision, a rendered page has the same network access as a file you open
|
||||
from disk in your browser. It **can** load scripts, styles, fonts, images and media from
|
||||
anywhere on the internet (a dashboard that pulls Chart.js from a CDN works), send requests to
|
||||
any host, and read the files in its own folder. It **cannot** act as Codeman: it has no access
|
||||
to Codeman's page, cookies or API, and no access to any folder other than its own. Since a page
|
||||
can send what it can read to any host, open HTML only from sources you trust, as you would on
|
||||
your own desktop. The **Page**
|
||||
pill in the viewer header switches to the source view (where **Edit** works as for any text
|
||||
file), and the viewer falls back to source on its own when a page cannot be rendered: a remote
|
||||
(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such
|
||||
as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it
|
||||
into a folder of its own). The raw file routes still serve HTML as a download only.
|
||||
|
||||
Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a
|
||||
one-gigabyte log does not try to paint one.
|
||||
@@ -155,9 +172,13 @@ expose everyone.
|
||||
|
||||
Paste from the clipboard or drag and drop straight onto the terminal. The image is written
|
||||
where the agent can read it and the reference is inserted into your prompt. On a phone, the
|
||||
image key in the keyboard bar opens the camera or photo library.
|
||||
toolbar's Attach button opens the camera or photo library, videos included, and puts the path
|
||||
on your prompt, where it behaves like text you typed; the Image button inside the Compose
|
||||
editor does the same into the editor. Dropping a video onto the terminal works the same way.
|
||||
|
||||
HEIC images from an iPhone are converted to JPEG on the way in.
|
||||
Videos (mp4, webm, mov, m4v, ogv) are stored as recorded, up to 1 GB each by default
|
||||
(`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than through server memory.
|
||||
|
||||
## Generated artifacts
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- [Agent CLIs](Agent-CLIs)
|
||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||
- [Working With Files](Working-With-Files)
|
||||
- [Git Worktrees](Git-Worktrees)
|
||||
- [Input And Voice](Input-And-Voice)
|
||||
- [Mobile Guide](Mobile-Guide)
|
||||
- [Keyboard Shortcuts](Keyboard-Shortcuts)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.1",
|
||||
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -133,6 +133,13 @@ its CSP for WebAssembly). Deploy = copy the bundle into Codeman's
|
||||
drop point. Pinch the panel again to move it anywhere. It stays inside the
|
||||
camera-owning page, so the hand keeps control (the old `window.app.detachSession`
|
||||
`window.open` was a one-way trip). A small twitch-and-release cancels.
|
||||
- **Tiles** (tile grid open): pinch anywhere on a tile and carry it onto
|
||||
another tile (the two trade places) or an empty cell (it moves there). A
|
||||
session tab carried onto a tile or an empty cell joins the grid there. With
|
||||
**Detach Tiles** on (App Settings, per device, off by default) a tile let go
|
||||
outside the grid opens as a window of its own, which needs the site's pop-ups
|
||||
allowed (a hand is no click). The moves are tile-grid.js's own
|
||||
(`tileDropTargetAt` / `dropOnTileTarget` / `detachTileAtPoint`).
|
||||
- **Run / Run Shell** — pinch over the **Run** (`#runBtn`) or **Run Shell**
|
||||
(`.btn-shell`) toolbar button and release in place to fire it; drifting too
|
||||
far first cancels the tap. The button list is `CLICK_SELECTOR` in `entry.ts`.
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
||||
// in place → fires the button's real click handler. Drift too far first and
|
||||
// it's treated as a stray move, not a tap.
|
||||
// • Tile "grab-to-move": with the tile grid open, pinch anywhere on a tile
|
||||
// and carry it onto another tile (the two trade places) or an empty cell
|
||||
// (it moves there). With Detach Tiles on (App Settings, per device, off by
|
||||
// default) a tile let go OUTSIDE the grid opens as a window of its own.
|
||||
// A session TAB carried onto a tile or an empty cell joins the grid there,
|
||||
// as a mouse-dragged tab does. Every move goes through app.js's own paths
|
||||
// (tile-grid.js tileDropTargetAt / dropOnTileTarget / detachTileAtPoint), so
|
||||
// the hand and the mouse can never disagree about what a drop does.
|
||||
//
|
||||
// Why in-page floats, not OS-window detach (decided 2026-06-08, see
|
||||
// docs/MULTIMONITOR_DESIGN.md): the hand only exists in the one page that owns
|
||||
@@ -52,11 +60,22 @@ declare global {
|
||||
saveSubagentWindowStates?: () => void;
|
||||
subagentWindowZIndex?: number;
|
||||
ultracodeWindowZIndex?: number;
|
||||
// The tile grid (tile-grid.js). All optional: an older dashboard without
|
||||
// them simply has no tile verbs for the hand.
|
||||
tileAtPoint?: (x: number, y: number) => string | null;
|
||||
canGrabTile?: (id: string) => boolean;
|
||||
tileDropTargetAt?: (x: number, y: number, draggedId: string) => TileTarget | null;
|
||||
dropOnTileTarget?: (draggedId: string, target: TileTarget) => boolean;
|
||||
isOverTileGrid?: (x: number, y: number) => boolean;
|
||||
tileDetachEnabled?: () => boolean;
|
||||
detachTileAtPoint?: (id: string, x: number, y: number, at?: { offsetX: number; offsetY: number }) => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_SELECTOR = '.session-tab';
|
||||
/** A tile of the open tile grid (tile-grid.js), keyed by `data-session-id`. */
|
||||
const TILE_SELECTOR = '#tileGrid .tile';
|
||||
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
||||
const PANEL_SELECTOR = '.cg-float';
|
||||
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
|
||||
@@ -81,11 +100,20 @@ const FLOAT_H = 420;
|
||||
const DETACH_PULL_PX = 70;
|
||||
/** If a button-pinch drifts more than this, it's a stray move, not a tap. */
|
||||
const TAP_CANCEL_PX = 45;
|
||||
/** How far (px) a grabbed tile must travel before it targets anything: a pinch
|
||||
* that barely moves is a stray one, and the tile stays where it is. */
|
||||
const TILE_MOVE_PX = 40;
|
||||
/** The width (px) of the small copy of a grabbed tile that follows the hand. */
|
||||
const TILE_GHOST_W = 280;
|
||||
|
||||
/** First letter colours: cyan left, violet right; green while pinching. */
|
||||
const handColor = (handedness: string, pinching: boolean): string =>
|
||||
pinching ? '#4ade80' : handedness === 'Right' ? '#a78bfa' : '#38bdf8';
|
||||
|
||||
/** Where a session carried by the hand would land in the tile grid: another
|
||||
* tile, or an empty cell (tile-grid.js tileDropTargetAt). */
|
||||
type TileTarget = { kind: 'tile'; id: string; el: HTMLElement } | { kind: 'cell'; cell: number; el: HTMLElement };
|
||||
|
||||
/** A floating in-page session panel (an iframe of `/session/:id`) the hand can
|
||||
* place and re-grab. Stays in this page's DOM, so it never leaves hand reach. */
|
||||
interface FloatingPanel {
|
||||
@@ -107,6 +135,29 @@ type Grab =
|
||||
oy: number;
|
||||
/** Pulled past the float-out threshold at least once. */
|
||||
armed: boolean;
|
||||
/** With the tile grid open: the tile or empty cell it would join (highlighted). */
|
||||
target: TileTarget | null;
|
||||
}
|
||||
| {
|
||||
/** A tile of the open grid, carried to another cell or (Detach Tiles)
|
||||
* out of the grid. The tile itself never moves until the drop: it dims
|
||||
* (`tile--dragging`, the mouse drag's own class) and a small copy of
|
||||
* it follows the hand. */
|
||||
kind: 'tile';
|
||||
id: string;
|
||||
el: HTMLElement;
|
||||
ghost: HTMLElement;
|
||||
/** Where in the tile the hand closed, so a pop-out window lands under it. */
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
/** Travelled past TILE_MOVE_PX at least once. */
|
||||
armed: boolean;
|
||||
/** The tile or empty cell it would drop onto (highlighted). */
|
||||
target: TileTarget | null;
|
||||
/** Outside the grid with Detach Tiles on: letting go opens a window. */
|
||||
out: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'panel';
|
||||
@@ -224,7 +275,7 @@ class GestureBridge {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add('on');
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
this.status.textContent = 'on: pinch a tab, tile, window, or button';
|
||||
} catch (err) {
|
||||
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
||||
// (number/string), so `(err as Error).message` was logging "undefined".
|
||||
@@ -295,6 +346,35 @@ class GestureBridge {
|
||||
return;
|
||||
}
|
||||
|
||||
// A tile of the open tile grid → carry it to another cell, or (Detach
|
||||
// Tiles) out of the grid. Anywhere on the tile: the hand is choosing the
|
||||
// whole tile, as with the agent windows. A tile that can do neither (alone
|
||||
// or zoomed, Detach Tiles off) is left alone, so the pinch falls through.
|
||||
const tileId = window.app?.tileAtPoint?.(x, y) ?? null;
|
||||
const tileEl = tileId ? this.hitClosest(x, y, TILE_SELECTOR) : null;
|
||||
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
|
||||
const rect = tileEl.getBoundingClientRect();
|
||||
const ghost = this.tileGhost(tileEl, rect);
|
||||
document.body.append(ghost);
|
||||
tileEl.classList.add('tile--dragging');
|
||||
this.grabs.set(hand, {
|
||||
kind: 'tile',
|
||||
id: tileId,
|
||||
el: tileEl,
|
||||
ghost,
|
||||
offsetX: Math.max(0, Math.min(x - rect.left, rect.width)),
|
||||
offsetY: Math.max(0, Math.min(y - rect.top, rect.height)),
|
||||
ox: x,
|
||||
oy: y,
|
||||
armed: false,
|
||||
target: null,
|
||||
out: false,
|
||||
});
|
||||
this.positionGhost(ghost, x, y);
|
||||
this.status.textContent = 'moving tile';
|
||||
return;
|
||||
}
|
||||
|
||||
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
||||
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
@@ -308,7 +388,7 @@ class GestureBridge {
|
||||
document.body.append(ghost);
|
||||
|
||||
tab.classList.add('cg-grabbed');
|
||||
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false });
|
||||
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false, target: null });
|
||||
this.positionGhost(ghost, x, y);
|
||||
return;
|
||||
}
|
||||
@@ -325,13 +405,42 @@ class GestureBridge {
|
||||
|
||||
private onDrag(hand: string, x: number, y: number): void {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === 'tile') {
|
||||
this.positionGhost(grab.ghost, x, y);
|
||||
if (!grab.armed && Math.hypot(x - grab.ox, y - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
|
||||
if (!grab.armed) return;
|
||||
const app = window.app;
|
||||
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
|
||||
this.setTileTarget(grab, target);
|
||||
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x, y);
|
||||
grab.out = out;
|
||||
grab.ghost.classList.toggle('cg-armed', !!target);
|
||||
grab.ghost.classList.toggle('cg-out', out);
|
||||
this.status.textContent = target
|
||||
? target.kind === 'tile'
|
||||
? 'release to swap tiles'
|
||||
: 'release to move the tile here'
|
||||
: out
|
||||
? 'release to open in a new window'
|
||||
: 'moving tile';
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'tab') {
|
||||
this.positionGhost(grab.ghost, x, y);
|
||||
const pulled = Math.hypot(x - grab.ox, y - grab.oy) >= DETACH_PULL_PX;
|
||||
if (pulled !== grab.armed) {
|
||||
// The tile grid open: a tile or an empty cell under the hand takes the
|
||||
// tab there, as a mouse-dragged tab does.
|
||||
const target = pulled ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
|
||||
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
|
||||
this.setTileTarget(grab, target);
|
||||
if (pulled !== grab.armed || targetChanged) {
|
||||
grab.armed = pulled;
|
||||
grab.ghost.classList.toggle('cg-armed', pulled);
|
||||
this.status.textContent = pulled ? 'release to float out' : 'on — pinch a tab';
|
||||
this.status.textContent = target
|
||||
? 'release to tile it here'
|
||||
: pulled
|
||||
? 'release to float out'
|
||||
: 'on — pinch a tab';
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -354,17 +463,44 @@ class GestureBridge {
|
||||
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
this.status.textContent = 'on: pinch a tab, tile, window, or button';
|
||||
}
|
||||
}
|
||||
|
||||
private onDrop(hand: string, x: number, y: number): void {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === 'tile') {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove('tile--dragging');
|
||||
this.setTileTarget(grab, null);
|
||||
const app = window.app;
|
||||
if (!grab.armed) {
|
||||
this.flash('cancelled');
|
||||
return;
|
||||
}
|
||||
// Asked again at the drop: the grid may have changed since the last frame.
|
||||
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
|
||||
if (target) {
|
||||
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? 'moved tile' : 'cancelled');
|
||||
} else if (grab.out && !app?.isOverTileGrid?.(x, y)) {
|
||||
// A window opened with no click behind it is what popup blockers stop:
|
||||
// detachSession then says so in a toast and the tile stays.
|
||||
const opened = app?.detachTileAtPoint?.(grab.id, x, y, { offsetX: grab.offsetX, offsetY: grab.offsetY });
|
||||
this.flash(opened ? 'opened in a new window' : 'pop-out blocked: allow popups for this site');
|
||||
} else {
|
||||
this.flash('cancelled');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'tab') {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove('cg-grabbed');
|
||||
if (grab.armed) this.floatPanel(grab.id, x, y);
|
||||
this.setTileTarget(grab, null);
|
||||
const target = grab.armed ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
|
||||
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? 'tiled' : 'cancelled');
|
||||
else if (grab.armed) this.floatPanel(grab.id, x, y);
|
||||
else this.flash('cancelled');
|
||||
return;
|
||||
}
|
||||
@@ -505,6 +641,33 @@ class GestureBridge {
|
||||
ghost.style.top = `${y}px`;
|
||||
}
|
||||
|
||||
/** A small copy of a tile to follow the hand: its header (a copy: no
|
||||
* listeners come along) over an empty body, in the tile's proportions. */
|
||||
private tileGhost(tileEl: HTMLElement, rect: DOMRect): HTMLElement {
|
||||
const ghost = el('div', 'cg-ghost cg-tile-ghost');
|
||||
const width = Math.min(TILE_GHOST_W, rect.width);
|
||||
ghost.style.width = `${width}px`;
|
||||
ghost.style.height = `${Math.max(48, Math.round((width * rect.height) / Math.max(1, rect.width)))}px`;
|
||||
const header = tileEl.querySelector('.tile-header');
|
||||
if (header) {
|
||||
const copy = header.cloneNode(true) as HTMLElement;
|
||||
copy.removeAttribute('draggable');
|
||||
ghost.append(copy);
|
||||
}
|
||||
ghost.append(el('div', 'cg-tile-ghost-body'));
|
||||
return ghost;
|
||||
}
|
||||
|
||||
/** Highlights the tile or empty cell a carried session would drop onto
|
||||
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
|
||||
private setTileTarget(grab: { target: TileTarget | null }, target: TileTarget | null): void {
|
||||
if (grab.target?.el !== target?.el) {
|
||||
grab.target?.el.classList.remove('tile--drop-target');
|
||||
target?.el.classList.add('tile--drop-target');
|
||||
}
|
||||
grab.target = target;
|
||||
}
|
||||
|
||||
private cancelAllGrabs(): void {
|
||||
// Floats themselves persist (they're placed windows) — only release any
|
||||
// in-progress grab cleanly, restoring a moved panel's interactivity.
|
||||
@@ -512,6 +675,11 @@ class GestureBridge {
|
||||
if (grab.kind === 'tab') {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove('cg-grabbed');
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === 'tile') {
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove('tile--dragging');
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === 'panel') {
|
||||
grab.panel.el.style.pointerEvents = '';
|
||||
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
|
||||
@@ -525,6 +693,7 @@ class GestureBridge {
|
||||
document
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
|
||||
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t) => t.classList.remove('tile--dragging'));
|
||||
}
|
||||
|
||||
private onStatus(fps: number, hands: HandState[]): void {
|
||||
@@ -607,6 +776,13 @@ function injectStyles(): void {
|
||||
outline: 2px solid #38bdf8; outline-offset: -2px;
|
||||
}
|
||||
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
|
||||
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
|
||||
.cg-tile-ghost {
|
||||
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
|
||||
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
|
||||
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
|
||||
.cg-dock {
|
||||
position: fixed; right: 12px; bottom: 156px; z-index: ${Z + 3};
|
||||
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "1.41.0",
|
||||
"version": "1.41.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -29,6 +29,9 @@ const SOURCE = 'skills/codeman';
|
||||
const PLUGIN_DIR = `plugins/${PLUGIN_NAME}`;
|
||||
const MIRROR = `${PLUGIN_DIR}/skills/codeman`;
|
||||
const MANIFESTS = [`${PLUGIN_DIR}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json'];
|
||||
// The MCP registry manifest for `codeman mcp` (published with `mcp-publisher` from its
|
||||
// directory): the server and its npm package both carry the release version.
|
||||
const MCP_REGISTRY_MANIFEST = 'config/mcp-registry/server.json';
|
||||
|
||||
const check = process.argv.includes('--check');
|
||||
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
@@ -78,6 +81,21 @@ for (const file of MANIFESTS) {
|
||||
if (changed && !check) writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// 3. The MCP registry manifest.
|
||||
{
|
||||
const json = JSON.parse(readFileSync(MCP_REGISTRY_MANIFEST, 'utf8'));
|
||||
const targets = [json, ...(json.packages ?? []).filter((p) => p.registryType === 'npm')];
|
||||
let changed = false;
|
||||
for (const target of targets) {
|
||||
if (target.version !== version) {
|
||||
drift.push(`${MCP_REGISTRY_MANIFEST}: ${target.version} -> ${version}`);
|
||||
target.version = version;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed && !check) writeFileSync(MCP_REGISTRY_MANIFEST, JSON.stringify(json, null, 2) + '\n');
|
||||
}
|
||||
|
||||
if (drift.length === 0) {
|
||||
console.log(`plugin in step: mirror identical, manifests at ${version}`);
|
||||
} else if (check) {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { basename, extname, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, extname, isAbsolute, join } from 'node:path';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
@@ -148,6 +149,11 @@ class AttachmentRegistry {
|
||||
|
||||
export const attachmentRegistry = new AttachmentRegistry();
|
||||
|
||||
/** Every extension {@link isSupportedAttachmentExtension} accepts (lowercase, no dot). */
|
||||
export function supportedAttachmentExtensions(): string[] {
|
||||
return [...SUPPORTED_ATTACHMENT_EXTENSIONS];
|
||||
}
|
||||
|
||||
export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
|
||||
}
|
||||
@@ -330,13 +336,19 @@ export async function registerExternalAttachment(
|
||||
requestedPath: string,
|
||||
options: RegisterExternalAttachmentOptions = {}
|
||||
): Promise<AttachmentRegistrationResult> {
|
||||
if (!requestedPath || !isAbsolute(requestedPath)) {
|
||||
// `~/…` is the home of the session's HOST: expanded here for a local session,
|
||||
// and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
|
||||
// whose home this server does not know. A clicked terminal link sends it as is
|
||||
// (Claude Code echoes attachments as `~/…`). Every guard below runs on the
|
||||
// resolved absolute path either way.
|
||||
const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
|
||||
if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
|
||||
throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
|
||||
}
|
||||
|
||||
const resolved = await (options.remote
|
||||
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
|
||||
: resolveLocalAttachment(requestedPath));
|
||||
: resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
|
||||
|
||||
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
|
||||
// path before doing anything else.
|
||||
|
||||
@@ -253,20 +253,21 @@ export interface RequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
/** Socket timeout; long-polls pass their own timeout plus headroom. */
|
||||
timeoutMs?: number;
|
||||
/** Aborts the request (an MCP client cancelling a long wait); the promise rejects. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
|
||||
|
||||
/** Every request carries these; they are ignored on endpoints that do not read them. */
|
||||
export function baseHeaders(ctx: AgentContext): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||
// may create one (see agentSpawn), never anything else.
|
||||
'X-Codeman-Parent-Session': ctx.selfId,
|
||||
};
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
// Tags sessions this caller spawns as its children (lineage in the web UI).
|
||||
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
|
||||
// directory as deletable agent scratch, so it rides only the spawn request that
|
||||
// may create one (see agentSpawn), never anything else. Absent for a caller outside
|
||||
// any session (`codeman mcp` launched by a desktop MCP client), which has no self.
|
||||
if (ctx.selfId) headers['X-Codeman-Parent-Session'] = ctx.selfId;
|
||||
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
|
||||
if (authorization) headers.Authorization = authorization;
|
||||
return headers;
|
||||
@@ -296,10 +297,14 @@ export const httpRequest: ApiRequest = (ctx, options) => {
|
||||
rejectUnauthorized: false,
|
||||
headers,
|
||||
timeout: options.timeoutMs ?? 30_000,
|
||||
signal: options.signal,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
// A connection dropped mid-body emits 'error' and never 'end'; without this the
|
||||
// promise never settles (and a long-lived `codeman mcp` never answers the call).
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf-8');
|
||||
let json: ApiEnvelope | undefined;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import type { AgentSkillDir } from './config/cli-registry/types.js';
|
||||
import { getCli, listClis } from './config/cli-registry/registry.js';
|
||||
import { registerAgentCommands } from './cli-agent.js';
|
||||
import { resolveMcpConfig, runMcpStdio } from './mcp-server.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
@@ -298,6 +299,12 @@ skillCmd
|
||||
const target = resolveSkillTarget({ case: options.case, skillDir });
|
||||
const result = await removeAgentSkillFrom(target);
|
||||
if (result === 'absent') continue;
|
||||
if (result === 'symlink') {
|
||||
// A shared-skills layout (`.agents/skills -> ../.claude/skills`) is deliberate:
|
||||
// skip it rather than fail the whole sweep.
|
||||
console.log(palette.warn(`- Skipped ${target}: it (or its parent) is a symlink, so it was not touched`));
|
||||
continue;
|
||||
}
|
||||
removedAny = true;
|
||||
const message = describeSkillResult(result, target);
|
||||
if (message.ok) console.log(palette.ok(`✓ ${message.text}`));
|
||||
@@ -318,6 +325,25 @@ skillCmd
|
||||
|
||||
registerAgentCommands(program);
|
||||
|
||||
// ============ MCP server (any MCP client drives Codeman) ============
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description(
|
||||
'Run a Model Context Protocol server on stdio, so MCP clients (Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, ...) can start, prompt, wait on, read and delete Codeman sessions'
|
||||
)
|
||||
.option('--url <url>', 'Codeman server URL (default: $CODEMAN_API_URL; never guessed)')
|
||||
.option(
|
||||
'--allow-any-session',
|
||||
'Let the tools prompt, interrupt and delete sessions this server did not start (default: only its own)'
|
||||
)
|
||||
.action(async (options: { url?: string; allowAnySession?: boolean }) => {
|
||||
await runMcpStdio(
|
||||
resolveMcpConfig({ url: options.url, allowAnySession: options.allowAnySession, version: pkg.version })
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
|
||||
@@ -113,6 +113,16 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a single video uploaded via POST
|
||||
* /api/sessions/:id/paste-image?kind=video (the phone toolbar's Attach button
|
||||
* offers the camera roll's videos beside its photos). Videos stream to disk
|
||||
* rather than through memory, so this is a disk-fill sanity bound, not a memory
|
||||
* bound: a minute of 4K60 from an iPhone is about 400MB, so the image cap would
|
||||
* refuse most clips. Override: CODEMAN_MAX_PASTE_VIDEO_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// ============================================================================
|
||||
// File Download Limits
|
||||
// ============================================================================
|
||||
|
||||
@@ -297,6 +297,10 @@ const capabilitiesSchema = z
|
||||
wheelForward: z
|
||||
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
||||
.strict(),
|
||||
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
|
||||
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
|
||||
// Optional: absent means no paging, the safe default for an unmeasured CLI.
|
||||
transcriptPageKeys: z.boolean().optional(),
|
||||
keyboardAccessory: z.enum(['agent', 'shell']),
|
||||
privilegedCommandGate: z.boolean(),
|
||||
startMode: z.enum(['interactive', 'shell']),
|
||||
@@ -449,6 +453,9 @@ const capabilitiesSchema = z
|
||||
.object({
|
||||
// Home-relative, no traversal: sync writes to this path.
|
||||
path: mcpRelativePath,
|
||||
// Home-relative fallback used when `path` is absent. Same no-traversal rule as `path`:
|
||||
// sync reads it and, when it parses as plain JSON, writes to it.
|
||||
altPath: mcpRelativePath.optional(),
|
||||
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
|
||||
// keyed by the same type, so an adapter-less format fails to compile there.
|
||||
format: z.enum([
|
||||
|
||||
@@ -285,6 +285,10 @@ const CLAUDE: CliEntry = {
|
||||
// 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' },
|
||||
// A repaint-mode Claude pane keeps no local history, so when the wheel is not
|
||||
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
|
||||
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
|
||||
transcriptPageKeys: true,
|
||||
keyboardAccessory: 'agent',
|
||||
privilegedCommandGate: false,
|
||||
startMode: 'interactive',
|
||||
@@ -513,6 +517,11 @@ const OPENCODE: CliEntry = {
|
||||
...AGENTS_DIR_SKILL,
|
||||
altScreen: 'strip-mux-and-mouse',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
|
||||
// Pages its own transcript on PageUp/PageDown (`messages_page_up/down`) and ignores
|
||||
// SGR wheel reports, and its TUI runs on the alternate screen (1.18.31: tmux
|
||||
// `alternate_on=1`, `history_size=0`), so a pane keeps no local history and the
|
||||
// wheel is turned into page keys or it does nothing at all (#555).
|
||||
transcriptPageKeys: true,
|
||||
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
|
||||
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
|
||||
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
|
||||
@@ -553,6 +562,7 @@ const OPENCODE: CliEntry = {
|
||||
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||
mcpConfig: {
|
||||
path: '.config/opencode/opencode.json',
|
||||
altPath: '.config/opencode/opencode.jsonc',
|
||||
format: 'opencode-json',
|
||||
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||
},
|
||||
@@ -724,6 +734,11 @@ const CODEX: CliEntry = {
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward
|
||||
// ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local,
|
||||
// alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp
|
||||
// moved the visible transcript while SGR wheel reports and local scrolling did nothing.
|
||||
// This is the page-key fallback for a hollow pane, not wheel forwarding.
|
||||
transcriptPageKeys: true,
|
||||
maxFrameBytes: 32 * 1024,
|
||||
// codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so
|
||||
// the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off.
|
||||
|
||||
@@ -515,6 +515,20 @@ export interface CliCapabilities {
|
||||
};
|
||||
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
|
||||
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
|
||||
/**
|
||||
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
|
||||
* a pane with no local history can be turned into those keys instead of doing nothing
|
||||
* (`_maybePageCliTranscript` in terminal-ui.js). Read by the frontend through
|
||||
* `window.__codemanTranscriptPageKeys`, which the server builds from this field.
|
||||
*
|
||||
* ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends
|
||||
* plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`).
|
||||
* A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its
|
||||
* `wheelForward` stays 'never') yet pages on PageUp/PageDown.
|
||||
*
|
||||
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
|
||||
*/
|
||||
transcriptPageKeys?: boolean;
|
||||
keyboardAccessory: 'agent' | 'shell';
|
||||
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
|
||||
privilegedCommandGate: boolean;
|
||||
@@ -665,7 +679,12 @@ export interface CliCapabilities {
|
||||
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
|
||||
* only follows `$HOME`.
|
||||
*/
|
||||
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
|
||||
mcpConfig?: {
|
||||
path: string;
|
||||
altPath?: string;
|
||||
format: McpConfigFormat;
|
||||
relocation?: { envVar: string; path: string };
|
||||
};
|
||||
/**
|
||||
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
|
||||
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @fileoverview Folder listings for the file-preview folder grid.
|
||||
*
|
||||
* A folder the user opens (a clicked folder path, or "Folder" in the file
|
||||
* preview) is resolved, checked against the same attachment guard a single file
|
||||
* goes through, and GRANTED an id. The grid then asks for files by
|
||||
* `folderId + file name` only, so browser requests never carry arbitrary paths —
|
||||
* the rule the attachment routes follow for single files.
|
||||
*
|
||||
* Grants live in their own small registry, not in the attachment registry: that one
|
||||
* is capped at 200 records per session, and a folder of 300 renders would evict the
|
||||
* session's real attachments.
|
||||
*
|
||||
* Listings contain non-hidden subfolders and files whose type the preview supports
|
||||
* (`isSupportedAttachmentExtension`), newest first, capped at
|
||||
* {@link MAX_FOLDER_ENTRIES}. Local folders are read with `fs`; a remote (SSH) case's
|
||||
* folder is listed on its host in one ssh round trip (`remoteListFolder`).
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { extname, isAbsolute, join, posix } from 'node:path';
|
||||
import {
|
||||
getAttachmentType,
|
||||
isSupportedAttachmentExtension,
|
||||
supportedAttachmentExtensions,
|
||||
} from './attachment-registry.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
|
||||
import { isMultiUserMode, userSpacePath } from './config/multiuser.js';
|
||||
import { remoteListFolder, RemoteFileAccessError, type RemoteFolderEntry } from './remote-files.js';
|
||||
import type { AttachmentDetectedType, AuthUser } from './types.js';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { describeUnknownPath, probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
|
||||
/** Most entries (files + subfolders) one listing returns. */
|
||||
export const MAX_FOLDER_ENTRIES = 1000;
|
||||
|
||||
/** Folder grants kept per session; the oldest is dropped beyond this. */
|
||||
const MAX_FOLDER_GRANTS_PER_SESSION = 32;
|
||||
|
||||
export interface FolderFileEntry {
|
||||
name: string;
|
||||
extension: string;
|
||||
attachmentType: AttachmentDetectedType;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface FolderListing {
|
||||
folderId: string;
|
||||
/** The folder with symlinks resolved (on the session's host). */
|
||||
path: string;
|
||||
files: FolderFileEntry[];
|
||||
/** Non-hidden subfolder names, sorted. */
|
||||
folders: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export class FolderListingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface FolderGrant {
|
||||
folderId: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
class FolderGrants {
|
||||
private bySession = new Map<string, Map<string, FolderGrant>>();
|
||||
|
||||
grant(sessionId: string, dir: string): string {
|
||||
let grants = this.bySession.get(sessionId);
|
||||
if (!grants) {
|
||||
grants = new Map();
|
||||
this.bySession.set(sessionId, grants);
|
||||
}
|
||||
for (const existing of grants.values()) {
|
||||
if (existing.dir === dir) {
|
||||
// Refresh its position so an often-used folder is the last to be dropped.
|
||||
grants.delete(existing.folderId);
|
||||
grants.set(existing.folderId, existing);
|
||||
return existing.folderId;
|
||||
}
|
||||
}
|
||||
const folderId = `fld_${randomUUID()}`;
|
||||
grants.set(folderId, { folderId, dir });
|
||||
while (grants.size > MAX_FOLDER_GRANTS_PER_SESSION) {
|
||||
const oldest = grants.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
grants.delete(oldest);
|
||||
}
|
||||
return folderId;
|
||||
}
|
||||
|
||||
get(sessionId: string, folderId: string): FolderGrant | undefined {
|
||||
return this.bySession.get(sessionId)?.get(folderId);
|
||||
}
|
||||
|
||||
clearSession(sessionId: string): void {
|
||||
this.bySession.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const folderGrants = new FolderGrants();
|
||||
|
||||
/**
|
||||
* A file name inside a granted folder, or null when it is not one: no path
|
||||
* separator, not hidden (which also rules out `.`/`..`; the listing leaves hidden
|
||||
* entries out, so the routes do too), no NUL, and a type the preview supports.
|
||||
*/
|
||||
export function validFolderFileName(name: unknown): string | null {
|
||||
if (typeof name !== 'string' || !name || name.length > 255) return null;
|
||||
if (name.startsWith('.') || name.includes('/') || name.includes('\\') || name.includes('\0')) return null;
|
||||
const extension = extname(name).toLowerCase().replace(/^\./, '');
|
||||
return isSupportedAttachmentExtension(extension) ? name : null;
|
||||
}
|
||||
|
||||
/** Where a granted folder's file lives (on the session's host). */
|
||||
export function folderFilePath(dir: string, name: string): string {
|
||||
return posix.join(dir, name);
|
||||
}
|
||||
|
||||
interface ListFolderOptions {
|
||||
sessionWorkingDir?: string;
|
||||
remote?: SessionRemote;
|
||||
/** The requesting identity; in multi-user mode a regular user is held to {@link folderAllowedForUser}. */
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user rule for folder grants, the same one the filesystem path picker follows:
|
||||
* a regular user reaches only their own space (`userSpacePath`) or the session's
|
||||
* `workingDir`. User spaces live inside `homedir()`, so without it a user could list
|
||||
* and read another user's space. Always true in single-user mode and for admins; a
|
||||
* remote session's folders are on its host, where only the `workingDir` applies.
|
||||
*
|
||||
* @param path Already resolved (realpath on the session's host).
|
||||
*/
|
||||
export function folderAllowedForUser(
|
||||
path: string,
|
||||
user: AuthUser | undefined,
|
||||
scope: { workingDir?: string; remote?: SessionRemote }
|
||||
): boolean {
|
||||
if (!isMultiUserMode()) return true;
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
if (scope.remote) return !!scope.workingDir && isUnderTree(path, scope.workingDir);
|
||||
return [userSpacePath(user.username), scope.workingDir].some(
|
||||
(root) => !!root && !!validateSessionFilePath(root, path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, guard, list and grant a folder for `sessionId`.
|
||||
*
|
||||
* A local folder is probed bounded first (`probePathKind`): one on a network mount
|
||||
* that stopped answering must not freeze the server, and "did not answer" is never
|
||||
* reported as "not found". The guard runs on the resolved folder before it is read.
|
||||
*
|
||||
* @param requestedPath Absolute, or `~/…` (the home of the session's host).
|
||||
* @throws FolderListingError 400 not absolute / not a folder, 403 blocked or outside the
|
||||
* user's space, 404 missing, 502 remote unreachable, 503 local folder not answering.
|
||||
*/
|
||||
export async function listFolder(
|
||||
sessionId: string,
|
||||
requestedPath: string,
|
||||
options: ListFolderOptions = {}
|
||||
): Promise<FolderListing> {
|
||||
const homeRelative = typeof requestedPath === 'string' && (requestedPath === '~' || requestedPath.startsWith('~/'));
|
||||
if (!requestedPath || typeof requestedPath !== 'string' || (!isAbsolute(requestedPath) && !homeRelative)) {
|
||||
throw new FolderListingError('Folder path must be absolute');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const workingDir = options.sessionWorkingDir;
|
||||
const assertAllowed = (dir: string): void => {
|
||||
const confined = !guard.confineToWorkspace
|
||||
? true
|
||||
: options.remote
|
||||
? !!workingDir && isUnderTree(dir, workingDir)
|
||||
: !!workingDir && !!validateSessionFilePath(workingDir, dir);
|
||||
if (!confined || isBlockedAttachmentPath(dir, guard.blockedTrees)) {
|
||||
throw new FolderListingError('Access to this folder is blocked', 403);
|
||||
}
|
||||
if (!folderAllowedForUser(dir, options.user, { workingDir, remote: options.remote })) {
|
||||
throw new FolderListingError('Folder is outside your space', 403);
|
||||
}
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
let entries: RemoteFolderEntry[];
|
||||
let truncated = false;
|
||||
if (options.remote) {
|
||||
const remotePath = requestedPath === '~' ? '~/' : requestedPath;
|
||||
let listing;
|
||||
try {
|
||||
listing = await remoteListFolder(options.remote, remotePath, supportedAttachmentExtensions(), MAX_FOLDER_ENTRIES);
|
||||
} catch (err) {
|
||||
throw new FolderListingError(err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable', 502);
|
||||
}
|
||||
if (!listing) throw new FolderListingError('Folder not found', 404);
|
||||
if (listing === 'not-a-folder') throw new FolderListingError('Not a folder');
|
||||
({ dir, entries, truncated } = listing);
|
||||
// One ssh call lists and resolves together, so the guard can only run after it;
|
||||
// nothing of a refused listing leaves this function.
|
||||
assertAllowed(dir);
|
||||
} else {
|
||||
const expanded = homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath;
|
||||
const kind = await probePathKind(expanded, { pastCap: true });
|
||||
if (kind === 'unknown') {
|
||||
throw new FolderListingError(describeUnknownPath('Folder', expanded, { pastCap: true }), 503);
|
||||
}
|
||||
if (kind === 'absent') throw new FolderListingError('Folder not found', 404);
|
||||
if (kind !== 'directory') throw new FolderListingError('Not a folder');
|
||||
try {
|
||||
dir = await fs.realpath(expanded);
|
||||
} catch {
|
||||
throw new FolderListingError('Folder not found', 404);
|
||||
}
|
||||
assertAllowed(dir);
|
||||
({ entries, truncated } = await listLocalFolder(dir));
|
||||
}
|
||||
|
||||
const files: FolderFileEntry[] = [];
|
||||
const folders: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
// A secret that happens to sit in an open folder stays out of the grid too.
|
||||
if (isBlockedAttachmentPath(folderFilePath(dir, entry.name), guard.blockedTrees)) continue;
|
||||
if (entry.kind === 'directory') {
|
||||
folders.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
const extension = extname(entry.name).toLowerCase().replace(/^\./, '');
|
||||
if (!isSupportedAttachmentExtension(extension)) continue;
|
||||
files.push({
|
||||
name: entry.name,
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: entry.size,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
});
|
||||
}
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
||||
folders.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return { folderId: folderGrants.grant(sessionId, dir), path: dir, files, folders, truncated };
|
||||
}
|
||||
|
||||
/** Local counterpart of the remote listing script: subfolders + supported files, stat'ed through symlinks. */
|
||||
async function listLocalFolder(dir: string): Promise<{ entries: RemoteFolderEntry[]; truncated: boolean }> {
|
||||
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
||||
const entries: RemoteFolderEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const dirent of dirents) {
|
||||
if (dirent.name.startsWith('.')) continue;
|
||||
if (entries.length >= MAX_FOLDER_ENTRIES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const extension = extname(dirent.name).toLowerCase().replace(/^\./, '');
|
||||
const maybeFile = isSupportedAttachmentExtension(extension);
|
||||
if (!dirent.isDirectory() && !maybeFile && !dirent.isSymbolicLink()) continue;
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(join(dir, dirent.name));
|
||||
} catch {
|
||||
continue; // dangling symlink, or gone since readdir
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
entries.push({ kind: 'directory', name: dirent.name, size: 0, mtimeMs: 0 });
|
||||
} else if (stat.isFile() && maybeFile) {
|
||||
entries.push({ kind: 'file', name: dirent.name, size: stat.size, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
return { entries, truncated };
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
/**
|
||||
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
|
||||
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, so several agents working
|
||||
* on one repository each get their own working tree instead of sharing the case folder (sessions
|
||||
* in one case share its folder, and two agents editing, building and committing in one checkout
|
||||
* step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one
|
||||
* through the existing create paths (`POST /api/quick-start` with `worktree`, or
|
||||
* `POST /api/sessions` with the worktree as `workingDir`).
|
||||
*
|
||||
* WHERE. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
|
||||
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
|
||||
* branch `worktree-<name>`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and
|
||||
* Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts).
|
||||
* Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a
|
||||
* worktree made here is one `claude --worktree <name>` would also find. The main checkout must not
|
||||
* show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL
|
||||
* `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not
|
||||
* already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its
|
||||
* hooks there for every claude session, and an untracked copy in a worktree makes
|
||||
* `git worktree remove` refuse forever (Claude Code itself ignores that file through the global
|
||||
* excludes file, which is not guaranteed to be set up on a given machine). The copy of Codeman's
|
||||
* agent skill (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) is excluded for
|
||||
* the same reason, for every skills folder the registry names (`WorktreeOptions.agentSkillDirs`).
|
||||
*
|
||||
* Split like the other git modules: a PURE half (name and branch validation, `worktree list`
|
||||
* parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that
|
||||
* spawns git through an injectable runner.
|
||||
*
|
||||
* Rules the code keeps and the tests pin:
|
||||
* - git is spawned with an argv array, never a shell, with `--` before every path operand, the
|
||||
* non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole
|
||||
* process group, and capped output. Read-only calls add `--no-optional-locks`.
|
||||
* - Names and branches are validated BEFORE they reach git (charset, length, then
|
||||
* `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands
|
||||
* are refused up front, since `--branch` would expand them).
|
||||
* - Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed. Every component of
|
||||
* that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the
|
||||
* worktree itself, is refused) and its real path must be exactly the expected one, so a
|
||||
* repository cannot redirect a create or a remove outside itself. What is left is a same-user
|
||||
* race (an agent swapping a directory between the check and git), which is accepted: that
|
||||
* agent already runs code as this user.
|
||||
* - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or
|
||||
* untracked files, and that refusal is the answer), refused while a live session runs inside
|
||||
* the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged
|
||||
* branch survives).
|
||||
* - git never runs for a case at or inside a Docker case workspace (or whose common git dir is
|
||||
* there): a container could plant a checkout filter or a hook, and `git worktree add` runs the
|
||||
* repository's post-checkout hook and filters on the HOST. On a local case that is acceptable,
|
||||
* since the user's agent runs code there anyway.
|
||||
* - A repository that merely encloses the case and is the home folder or above (a dotfiles repo
|
||||
* in `$HOME`) is not the case's repository, and a case folder below a repository's top level
|
||||
* is refused: the worktrees would not be where the case is.
|
||||
*
|
||||
* @module git-worktrees
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
|
||||
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
|
||||
|
||||
// ─── Limits ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Claude Code's own limit for a `--worktree` name (2.1.296). */
|
||||
export const MAX_WORKTREE_NAME_LENGTH = 64;
|
||||
/** Longest accepted branch name. git allows more; 200 covers every real one. */
|
||||
export const MAX_WORKTREE_BRANCH_LENGTH = 200;
|
||||
/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */
|
||||
export const WORKTREE_READ_TIMEOUT_MS = 15_000;
|
||||
/**
|
||||
* `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and
|
||||
* smudge filters (Git LFS downloads objects there), so it gets the longest budget.
|
||||
*/
|
||||
export const WORKTREE_ADD_TIMEOUT_MS = 180_000;
|
||||
/** `git worktree remove` runs a status of the tree first, then deletes it. */
|
||||
export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000;
|
||||
/** Captured stdout. `worktree list` is one short block per worktree. */
|
||||
const MAX_STDOUT_BYTES = 1_000_000;
|
||||
/** Captured stderr tail. */
|
||||
const MAX_STDERR_BYTES = 8_192;
|
||||
/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */
|
||||
export const MAX_WORKTREES_LISTED = 200;
|
||||
|
||||
/** Path segments of the managed worktrees folder, relative to the repository root. */
|
||||
export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'] as const;
|
||||
/** The `info/exclude` line that hides the managed worktrees from the main checkout. */
|
||||
export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/';
|
||||
/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */
|
||||
export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json';
|
||||
|
||||
/** A workspace-relative skills folder (`.claude/skills`, `.agents/skills`): plain segments only. */
|
||||
const SKILLS_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
|
||||
|
||||
/**
|
||||
* The `info/exclude` line for the copy of Codeman's agent skill in `skillsDir` of a workspace
|
||||
* (`<skillsDir>/codeman/`, written into the folder a session runs in while `agentSkillEnabled`
|
||||
* is on), or null for a folder that is not a plain relative path.
|
||||
*/
|
||||
export function agentSkillExcludeLine(skillsDir: string): string | null {
|
||||
if (!SKILLS_DIR_RE.test(skillsDir) || skillsDir.split('/').some((s) => s === '.' || s === '..')) return null;
|
||||
return `/${skillsDir}/codeman/`;
|
||||
}
|
||||
|
||||
// ─── Pure: names ─────────────────────────────────────────────────────────────
|
||||
|
||||
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
||||
|
||||
/**
|
||||
* Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits,
|
||||
* `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the
|
||||
* session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's
|
||||
* own ref rules, since the default branch is derived from the name).
|
||||
*/
|
||||
export function worktreeNameError(name: unknown): string | null {
|
||||
if (typeof name !== 'string' || name.length === 0) return 'A worktree name is required.';
|
||||
if (name.length > MAX_WORKTREE_NAME_LENGTH)
|
||||
return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`;
|
||||
if (!WORKTREE_NAME_RE.test(name))
|
||||
return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.';
|
||||
if (name.includes('..')) return 'A worktree name may not contain "..".';
|
||||
if (name.endsWith('.') || name.toLowerCase().endsWith('.lock'))
|
||||
return 'A worktree name may not end with "." or ".lock".';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-<name>`. */
|
||||
export function defaultWorktreeBranch(name: string): string {
|
||||
return `worktree-${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final
|
||||
* word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing
|
||||
* option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all.
|
||||
*/
|
||||
export function worktreeBranchError(branch: unknown): string | null {
|
||||
if (typeof branch !== 'string' || branch.length === 0) return 'A branch name is required.';
|
||||
if (branch.length > MAX_WORKTREE_BRANCH_LENGTH)
|
||||
return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`;
|
||||
if (!BRANCH_NAME_RE.test(branch))
|
||||
return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.';
|
||||
if (branch.includes('..') || branch.includes('//')) return 'A branch name may not contain ".." or "//".';
|
||||
if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock'))
|
||||
return 'A branch name may not end with "/", "." or ".lock".';
|
||||
if (branch.split('/').some((part) => part.startsWith('.'))) return 'No part of a branch name may start with ".".';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */
|
||||
export function worktreeBaseError(base: unknown): string | null {
|
||||
if (typeof base !== 'string' || !isSafeGitRef(base))
|
||||
return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Pure: paths ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** `<repo>/.claude/worktrees`. */
|
||||
export function managedWorktreesDir(repoRoot: string): string {
|
||||
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
|
||||
}
|
||||
|
||||
/** `<repo>/.claude/worktrees/<name>`. The caller validates `name` first. */
|
||||
export function managedWorktreePath(repoRoot: string, name: string): string {
|
||||
return join(managedWorktreesDir(repoRoot), name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed worktree name of `path`, or null when it is not exactly
|
||||
* `<repoRoot>/.claude/worktrees/<valid-name>`. Lexical: both sides must already be real paths.
|
||||
*/
|
||||
export function managedWorktreeName(repoRoot: string, path: string): string | null {
|
||||
const dir = managedWorktreesDir(repoRoot) + sep;
|
||||
const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path;
|
||||
if (!clean.startsWith(dir)) return null;
|
||||
const name = clean.slice(dir.length);
|
||||
if (!name || name.includes(sep)) return null;
|
||||
return worktreeNameError(name) === null ? name : null;
|
||||
}
|
||||
|
||||
/** True when `child` is `root` or inside it (lexical, on resolved paths). */
|
||||
export function isPathWithin(child: string, root: string): boolean {
|
||||
const c = resolve(child);
|
||||
const r = resolve(root);
|
||||
return c === r || c.startsWith(r === sep ? r : r + sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* `path` as the repository spells it: a path under `alias` (the case folder as its case resolves
|
||||
* it, which can run through a symlink) moved under `root` (the repository's real top level), else
|
||||
* `path` unchanged. Lexical, so it costs no filesystem call per session. Sessions keep the
|
||||
* `workingDir` they were created with, while the worktree paths git lists are real, so without
|
||||
* this a session spelled through the case's link counted for no worktree and did not block its
|
||||
* removal.
|
||||
*/
|
||||
export function respellUnder(path: string, alias: string, root: string): string {
|
||||
const p = resolve(path);
|
||||
const a = resolve(alias);
|
||||
if (a === resolve(root) || !isPathWithin(p, a)) return p;
|
||||
return join(root, p.slice(a.length));
|
||||
}
|
||||
|
||||
// ─── Pure: `git worktree list --porcelain` ───────────────────────────────────
|
||||
|
||||
export interface ParsedWorktree {
|
||||
path: string;
|
||||
/** Commit checked out (absent for a bare entry). */
|
||||
head: string | null;
|
||||
/** Short branch name (`refs/heads/` stripped), null when detached or bare. */
|
||||
branch: string | null;
|
||||
detached: boolean;
|
||||
bare: boolean;
|
||||
locked: boolean;
|
||||
lockReason?: string;
|
||||
prunable: boolean;
|
||||
prunableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when
|
||||
* they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are
|
||||
* raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged.
|
||||
*/
|
||||
export function unquoteGitPath(text: string): string {
|
||||
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) return text;
|
||||
const body = text.slice(1, -1);
|
||||
const bytes: number[] = [];
|
||||
const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 };
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\') {
|
||||
for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b);
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
if (next !== undefined && /[0-7]/.test(next)) {
|
||||
const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))![0];
|
||||
bytes.push(parseInt(oct, 8) & 0xff);
|
||||
i += oct.length;
|
||||
} else if (next !== undefined && next in simple) {
|
||||
bytes.push(simple[next]);
|
||||
i += 1;
|
||||
} else {
|
||||
bytes.push(92);
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes).toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu
|
||||
* 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths
|
||||
* are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII
|
||||
* repository paths readable.
|
||||
*/
|
||||
export function parseWorktreeList(text: string): ParsedWorktree[] {
|
||||
const out: ParsedWorktree[] = [];
|
||||
let cur: ParsedWorktree | null = null;
|
||||
const flush = () => {
|
||||
if (cur) out.push(cur);
|
||||
cur = null;
|
||||
};
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.replace(/\r$/, '');
|
||||
if (!line) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
const space = line.indexOf(' ');
|
||||
const key = space === -1 ? line : line.slice(0, space);
|
||||
const value = space === -1 ? '' : line.slice(space + 1);
|
||||
if (key === 'worktree') {
|
||||
flush();
|
||||
cur = {
|
||||
path: unquoteGitPath(value),
|
||||
head: null,
|
||||
branch: null,
|
||||
detached: false,
|
||||
bare: false,
|
||||
locked: false,
|
||||
prunable: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
const entry: ParsedWorktree = cur;
|
||||
if (key === 'HEAD') entry.head = value || null;
|
||||
else if (key === 'branch') entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null;
|
||||
else if (key === 'detached') entry.detached = true;
|
||||
else if (key === 'bare') entry.bare = true;
|
||||
else if (key === 'locked') {
|
||||
entry.locked = true;
|
||||
if (value) entry.lockReason = value;
|
||||
} else if (key === 'prunable') {
|
||||
entry.prunable = true;
|
||||
if (value) entry.prunableReason = value;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Pure: exclude file ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it:
|
||||
* the text ('' when nothing is missing) and the lines it adds. A file that does not end in a
|
||||
* newline gets one first, so an appended pattern never fuses with the user's last line.
|
||||
*/
|
||||
export function excludeAppendText(existing: string, lines: readonly string[]): { text: string; added: string[] } {
|
||||
const present = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
|
||||
const added = lines.filter((l) => !present.has(l));
|
||||
if (added.length === 0) return { text: '', added };
|
||||
const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
||||
return {
|
||||
text: `${lead}# Added by Codeman: git worktrees and the files Codeman writes into each workspace stay local\n${added.join('\n')}\n`,
|
||||
added,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Pure: git's answers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines
|
||||
* dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only.
|
||||
*/
|
||||
export function gitRefusalText(stderr: string): string {
|
||||
const clean = sanitizeGitOutput(stderr, 2000);
|
||||
const lines = clean
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !/^hint:/i.test(l));
|
||||
const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l));
|
||||
const text = (diagnostic.length ? diagnostic : lines).join('\n');
|
||||
return text.length > 1000 ? `${text.slice(0, 1000)}…` : text;
|
||||
}
|
||||
|
||||
// ─── IO: git runner ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorktreeGitResult {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
spawnError?: string;
|
||||
}
|
||||
|
||||
/** Runs `git <args>` in `cwd`; never throws. Injected so tests can refuse to run git at all. */
|
||||
export type WorktreeGitRunner = (
|
||||
cwd: string,
|
||||
args: string[],
|
||||
opts?: { timeoutMs?: number }
|
||||
) => Promise<WorktreeGitResult>;
|
||||
|
||||
/**
|
||||
* Global options for a call that only reads: no optional index refresh, no filesystem monitor,
|
||||
* no path quoting of non-ASCII names. Must precede the subcommand.
|
||||
*/
|
||||
const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false'];
|
||||
|
||||
/**
|
||||
* The production runner: argv only (no shell), the non-interactive environment, captured output
|
||||
* caps, and a timeout that signals the whole process group (`detached: true`), because
|
||||
* `git worktree add` fans out into checkout children, hooks and filters that a signal to the
|
||||
* parent alone would leave running.
|
||||
*/
|
||||
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
|
||||
new Promise<WorktreeGitResult>((resolveRun) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', args, {
|
||||
cwd,
|
||||
env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
} catch (err) {
|
||||
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) });
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stdoutBytes = 0;
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
const killTree = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
||||
}, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS);
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes <= MAX_STDOUT_BYTES) stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf-8');
|
||||
if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES);
|
||||
});
|
||||
const finish = (result: WorktreeGitResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolveRun(result);
|
||||
};
|
||||
child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) }));
|
||||
child.on('close', (code) => finish({ code, stdout, stderr, timedOut }));
|
||||
});
|
||||
|
||||
// ─── IO: results ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Failure codes, named after the API error codes the routes map them to. */
|
||||
export type WorktreeFailureCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'CONFLICT' | 'OPERATION_FAILED';
|
||||
|
||||
export interface WorktreeFailure {
|
||||
ok: false;
|
||||
code: WorktreeFailureCode;
|
||||
message: string;
|
||||
/** git's own words, when git refused (sanitized; render as text). */
|
||||
gitMessage?: string;
|
||||
}
|
||||
|
||||
const fail = (code: WorktreeFailureCode, message: string, gitMessage?: string): WorktreeFailure => ({
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
...(gitMessage ? { gitMessage } : {}),
|
||||
});
|
||||
|
||||
function gitFailure(run: WorktreeGitResult, what: string): WorktreeFailure {
|
||||
if (run.spawnError && /enoent/i.test(run.spawnError))
|
||||
return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).');
|
||||
if (run.timedOut) return fail('OPERATION_FAILED', `git timed out while ${what}.`);
|
||||
const text = gitRefusalText(run.stderr);
|
||||
return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text);
|
||||
}
|
||||
|
||||
// ─── IO: the case's repository ───────────────────────────────────────────────
|
||||
|
||||
export interface CaseRepository {
|
||||
/** The repository's top level, which is the case folder (real path). */
|
||||
repoRoot: string;
|
||||
/** The real common git dir (`.git` of the main checkout), where `info/exclude` lives. */
|
||||
commonDir: string;
|
||||
}
|
||||
|
||||
export interface WorktreeOptions {
|
||||
git?: WorktreeGitRunner;
|
||||
/** Host paths of every Docker case workspace: git never runs at or inside one. */
|
||||
dockerWorkspaces?: string[];
|
||||
/** Overrides `os.homedir()` for the unrelated-ancestor rule (tests). */
|
||||
home?: string;
|
||||
/**
|
||||
* Every workspace skills folder a session can get Codeman's agent skill in (the registry's
|
||||
* `capabilities.agentSkillDir` values, passed in as data by the caller). A create excludes
|
||||
* `<dir>/codeman/` for each, like the hooks file: a session in a worktree gets an untracked
|
||||
* copy there, and `git worktree remove` would refuse that worktree forever.
|
||||
*/
|
||||
agentSkillDirs?: readonly string[];
|
||||
}
|
||||
|
||||
const realOr = async (p: string): Promise<string> => {
|
||||
try {
|
||||
return await fs.realpath(p);
|
||||
} catch {
|
||||
return resolve(p);
|
||||
}
|
||||
};
|
||||
|
||||
const DOCKER_REFUSAL =
|
||||
'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.';
|
||||
|
||||
/**
|
||||
* The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs
|
||||
* git when the folder (or the repository's common git dir) is at or inside a Docker case workspace.
|
||||
*/
|
||||
export async function resolveCaseRepository(
|
||||
casePath: string,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; repo: CaseRepository } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
|
||||
if (await isInsideAny(casePath, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
|
||||
|
||||
const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
if (run.code !== 0 || run.spawnError || run.timedOut) {
|
||||
if (/must be run in a work tree/i.test(run.stderr)) {
|
||||
return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.');
|
||||
}
|
||||
if (/not a git repository/i.test(run.stderr)) {
|
||||
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
|
||||
}
|
||||
return gitFailure(run, 'reading the repository');
|
||||
}
|
||||
const [top, common] = run.stdout.split('\n').map((l) => l.trim());
|
||||
if (!top || !common) return fail('OPERATION_FAILED', 'git did not report the repository root.');
|
||||
const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]);
|
||||
const commonDir = await realOr(resolve(casePath, common));
|
||||
if (await isInsideAny(commonDir, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
|
||||
if (repoRoot !== caseReal) {
|
||||
if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) {
|
||||
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
|
||||
}
|
||||
return fail(
|
||||
'INVALID_INPUT',
|
||||
`Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.`
|
||||
);
|
||||
}
|
||||
return { ok: true, repo: { repoRoot, commonDir } };
|
||||
}
|
||||
|
||||
// ─── IO: listing ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CaseWorktree extends ParsedWorktree {
|
||||
/** The repository's main checkout (git lists it first). */
|
||||
main: boolean;
|
||||
/** The case folder itself (the main checkout, or a linked worktree linked as the case). */
|
||||
isCase: boolean;
|
||||
/** Under `<repo>/.claude/worktrees/<valid-name>`: Codeman may start sessions in it and remove it. */
|
||||
managed: boolean;
|
||||
/** The managed name, when `managed`. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function listCaseWorktrees(
|
||||
repo: CaseRepository,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; worktrees: CaseWorktree[]; truncated: boolean } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
if (run.code !== 0 || run.spawnError || run.timedOut) return gitFailure(run, 'listing worktrees');
|
||||
const parsed = parseWorktreeList(run.stdout);
|
||||
const truncated = parsed.length > MAX_WORKTREES_LISTED;
|
||||
const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index): CaseWorktree => {
|
||||
const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path);
|
||||
return {
|
||||
...wt,
|
||||
main: index === 0,
|
||||
isCase: wt.path === repo.repoRoot,
|
||||
managed: name !== null,
|
||||
...(name ? { name } : {}),
|
||||
};
|
||||
});
|
||||
return { ok: true, worktrees, truncated };
|
||||
}
|
||||
|
||||
// ─── IO: path guards ─────────────────────────────────────────────────────────
|
||||
|
||||
type DirState = 'dir' | 'absent' | 'symlink' | 'other';
|
||||
|
||||
async function lstatState(path: string): Promise<DirState> {
|
||||
try {
|
||||
const st = await fs.lstat(path);
|
||||
if (st.isSymbolicLink()) return 'symlink';
|
||||
return st.isDirectory() ? 'dir' : 'other';
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return 'absent';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure `<repo>/.claude` and `<repo>/.claude/worktrees` are real directories (created when
|
||||
* absent, refused when a symlink or a file) whose real path is exactly the lexical one.
|
||||
*/
|
||||
async function prepareWorktreesDir(repoRoot: string): Promise<{ ok: true; dir: string } | WorktreeFailure> {
|
||||
let cur = repoRoot;
|
||||
for (const segment of WORKTREES_DIR_SEGMENTS) {
|
||||
cur = join(cur, segment);
|
||||
const state = await lstatState(cur);
|
||||
if (state === 'symlink') {
|
||||
return fail(
|
||||
'INVALID_INPUT',
|
||||
`${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.`
|
||||
);
|
||||
}
|
||||
if (state === 'other') return fail('INVALID_INPUT', `${cur} exists and is not a directory.`);
|
||||
if (state === 'absent') {
|
||||
try {
|
||||
await fs.mkdir(cur);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
return fail('OPERATION_FAILED', `Could not create ${cur}: ${(err as Error).message}`);
|
||||
}
|
||||
if ((await lstatState(cur)) !== 'dir') return fail('INVALID_INPUT', `${cur} is not a plain directory.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((await realOr(cur)) !== cur) {
|
||||
return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`);
|
||||
}
|
||||
return { ok: true, dir: cur };
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed worktree `name` of `repo` as it is on disk now: every component a real directory
|
||||
* (no symlinks), the real path the expected one, and git listing it as a worktree of this
|
||||
* repository. Used by removal and by starting a session in it.
|
||||
*
|
||||
* `allowMissing` (removal only, never a session start) also accepts a worktree whose folder is
|
||||
* GONE (deleted by hand, or by an agent) while git still lists it, as prunable, at exactly the
|
||||
* managed path: no component on the way may be a symlink or a file, and `missing` says so.
|
||||
* Without it such a worktree was a dead end: it could not be removed, and its name could not be
|
||||
* used again (the branch exists, then git refuses "a missing but already registered worktree").
|
||||
*/
|
||||
export async function resolveManagedWorktree(
|
||||
repo: CaseRepository,
|
||||
name: string,
|
||||
opts: WorktreeOptions & { allowMissing?: boolean } = {}
|
||||
): Promise<{ ok: true; path: string; worktree: CaseWorktree; missing: boolean } | WorktreeFailure> {
|
||||
const nameError = worktreeNameError(name);
|
||||
if (nameError) return fail('INVALID_INPUT', nameError);
|
||||
const target = managedWorktreePath(repo.repoRoot, name);
|
||||
let cur = repo.repoRoot;
|
||||
let missing = false;
|
||||
for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) {
|
||||
cur = join(cur, segment);
|
||||
const state = await lstatState(cur);
|
||||
if (state === 'absent') {
|
||||
if (!opts.allowMissing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
|
||||
// Nothing below an absent folder exists, so nothing below it can redirect anything.
|
||||
missing = true;
|
||||
break;
|
||||
}
|
||||
if (state === 'symlink') {
|
||||
return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`);
|
||||
}
|
||||
if (state === 'other') return fail('INVALID_INPUT', `${cur} is not a directory.`);
|
||||
}
|
||||
if (!missing && (await realOr(target)) !== target) {
|
||||
return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`);
|
||||
}
|
||||
const listed = await listCaseWorktrees(repo, opts);
|
||||
if (!listed.ok) return listed;
|
||||
for (const wt of listed.worktrees) {
|
||||
if (wt.main) continue;
|
||||
if (missing) {
|
||||
// Lexical only: the folder is gone, so there is no real path to compare. git never calls a
|
||||
// LOCKED worktree prunable (prune skips it), so a locked one is taken too, and the caller's
|
||||
// lock check then refuses it with git's lock reason.
|
||||
if (wt.path === target && (wt.prunable || wt.locked)) {
|
||||
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (wt.path === target || (await realOr(wt.path)) === target) {
|
||||
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: false };
|
||||
}
|
||||
}
|
||||
if (missing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
|
||||
return fail(
|
||||
'NOT_FOUND',
|
||||
`${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── IO: excludes ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append each of `lines` that git does not already ignore to `<commonDir>/info/exclude`. Returns
|
||||
* the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never
|
||||
* written through).
|
||||
*/
|
||||
async function ensureExcluded(
|
||||
repo: CaseRepository,
|
||||
probes: Array<{ line: string; probe: string }>,
|
||||
git: WorktreeGitRunner
|
||||
): Promise<{ added: string[]; warning?: string }> {
|
||||
const needed: string[] = [];
|
||||
for (const { line, probe } of probes) {
|
||||
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
// 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an
|
||||
// extra exclude line is harmless and a missing one leaves the worktrees untracked.
|
||||
if (run.code !== 0) needed.push(line);
|
||||
}
|
||||
if (needed.length === 0) return { added: [] };
|
||||
const infoDir = join(repo.commonDir, 'info');
|
||||
const excludePath = join(infoDir, 'exclude');
|
||||
try {
|
||||
const infoState = await lstatState(infoDir);
|
||||
if (infoState === 'absent') await fs.mkdir(infoDir);
|
||||
else if (infoState !== 'dir')
|
||||
return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` };
|
||||
const fileState = await lstatState(excludePath);
|
||||
if (fileState === 'symlink' || fileState === 'dir') {
|
||||
return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` };
|
||||
}
|
||||
const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8');
|
||||
const { text, added } = excludeAppendText(existing, needed);
|
||||
if (text) await fs.appendFile(excludePath, text, { mode: 0o644 });
|
||||
return { added };
|
||||
} catch (err) {
|
||||
return { added: [], warning: `Could not update ${excludePath}: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IO: one operation per repository at a time ──────────────────────────────
|
||||
|
||||
const repoLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
|
||||
async function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = repoLocks.get(repoRoot) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
const tail = next.catch(() => undefined);
|
||||
repoLocks.set(repoRoot, tail);
|
||||
try {
|
||||
return await next;
|
||||
} finally {
|
||||
if (repoLocks.get(repoRoot) === tail) repoLocks.delete(repoRoot);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IO: create ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreateWorktreeRequest {
|
||||
name: string;
|
||||
/** New branch; default `worktree-<name>`. */
|
||||
branch?: string;
|
||||
/** Start point; default `HEAD` (the case's current commit). */
|
||||
base?: string;
|
||||
}
|
||||
|
||||
export interface CreatedWorktree {
|
||||
name: string;
|
||||
path: string;
|
||||
branch: string;
|
||||
base: string;
|
||||
/** The commit the new branch starts at. */
|
||||
head: string;
|
||||
/** Lines appended to the repository's `info/exclude` (empty when git already ignored them). */
|
||||
excludeAdded: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function createCaseWorktree(
|
||||
repo: CaseRepository,
|
||||
req: CreateWorktreeRequest,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; worktree: CreatedWorktree } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
const nameError = worktreeNameError(req.name);
|
||||
if (nameError) return fail('INVALID_INPUT', nameError);
|
||||
const branch = req.branch || defaultWorktreeBranch(req.name);
|
||||
const branchError = worktreeBranchError(branch);
|
||||
if (branchError) return fail('INVALID_INPUT', branchError);
|
||||
const base = req.base || 'HEAD';
|
||||
const baseError = worktreeBaseError(base);
|
||||
if (baseError) return fail('INVALID_INPUT', baseError);
|
||||
|
||||
return withRepoLock(repo.repoRoot, async () => {
|
||||
// A name already taken says so in those words, before the branch check would report the
|
||||
// derived branch instead. Rechecked after the folders are prepared, below.
|
||||
try {
|
||||
if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') {
|
||||
return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`);
|
||||
}
|
||||
} catch {
|
||||
/* unreadable: the check after the folders are prepared reports it */
|
||||
}
|
||||
// git's own word on the branch name. The output must be the input: `--branch` expands
|
||||
// shorthands, and anything it rewrote is not the name the user typed.
|
||||
const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], {
|
||||
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
|
||||
});
|
||||
if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) {
|
||||
return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr));
|
||||
}
|
||||
const existingBranch = await git(
|
||||
repo.repoRoot,
|
||||
[...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
|
||||
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
|
||||
);
|
||||
if (existingBranch.code === 0) {
|
||||
return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`);
|
||||
}
|
||||
const resolved = await git(
|
||||
repo.repoRoot,
|
||||
// No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a
|
||||
// leading `-`, so it cannot be read as an option.
|
||||
[...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`],
|
||||
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
|
||||
);
|
||||
const head = resolved.stdout.trim();
|
||||
if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) {
|
||||
return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`);
|
||||
}
|
||||
|
||||
const prepared = await prepareWorktreesDir(repo.repoRoot);
|
||||
if (!prepared.ok) return prepared;
|
||||
const target = join(prepared.dir, req.name);
|
||||
try {
|
||||
if ((await lstatState(target)) !== 'absent') {
|
||||
return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`);
|
||||
}
|
||||
} catch (err) {
|
||||
return fail('OPERATION_FAILED', `Could not inspect ${target}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const skillProbes: Array<{ line: string; probe: string }> = [];
|
||||
for (const dir of new Set(opts.agentSkillDirs ?? [])) {
|
||||
const line = agentSkillExcludeLine(dir);
|
||||
if (line) skillProbes.push({ line, probe: `${dir}/codeman` });
|
||||
}
|
||||
const excluded = await ensureExcluded(
|
||||
repo,
|
||||
[
|
||||
{ line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` },
|
||||
{ line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' },
|
||||
...skillProbes,
|
||||
],
|
||||
git
|
||||
);
|
||||
|
||||
// The resolved commit, not the ref text, after `--`: nothing here can be read as an option,
|
||||
// and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main.
|
||||
const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], {
|
||||
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
|
||||
});
|
||||
if (add.code !== 0 || add.spawnError || add.timedOut) {
|
||||
const text = gitRefusalText(add.stderr);
|
||||
if (/already exists/i.test(text)) return fail('ALREADY_EXISTS', `git refused: ${text}`, text);
|
||||
return gitFailure(add, 'creating the worktree');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
worktree: {
|
||||
name: req.name,
|
||||
path: target,
|
||||
branch,
|
||||
base,
|
||||
head,
|
||||
excludeAdded: excluded.added,
|
||||
warnings: excluded.warning ? [excluded.warning] : [],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── IO: remove ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RemoveWorktreeRequest {
|
||||
name: string;
|
||||
/** Also `git branch -d` the worktree's branch (never `-D`). */
|
||||
deleteBranch?: boolean;
|
||||
/** True when a live session runs at or inside `path`: the removal is refused. */
|
||||
isInUse: (path: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RemovedWorktree {
|
||||
name: string;
|
||||
path: string;
|
||||
branch: string | null;
|
||||
branchDeleted: boolean;
|
||||
/** git's refusal to delete the branch (typically "not fully merged"); the worktree is gone regardless. */
|
||||
branchError?: string;
|
||||
}
|
||||
|
||||
export async function removeCaseWorktree(
|
||||
repo: CaseRepository,
|
||||
req: RemoveWorktreeRequest,
|
||||
opts: WorktreeOptions = {}
|
||||
): Promise<{ ok: true; removed: RemovedWorktree } | WorktreeFailure> {
|
||||
const git = opts.git ?? runWorktreeGit;
|
||||
return withRepoLock(repo.repoRoot, async () => {
|
||||
// A worktree whose folder is gone is removable too: `git worktree remove` then only drops
|
||||
// git's record of it (measured on git 2.43: exit 0, nothing on disk touched).
|
||||
const found = await resolveManagedWorktree(repo, req.name, { ...opts, allowMissing: true });
|
||||
if (!found.ok) return found;
|
||||
if (await req.isInUse(found.path)) {
|
||||
return fail(
|
||||
'CONFLICT',
|
||||
`A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.`
|
||||
);
|
||||
}
|
||||
if (found.worktree.locked) {
|
||||
return fail(
|
||||
'CONFLICT',
|
||||
`Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.`
|
||||
);
|
||||
}
|
||||
const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], {
|
||||
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
|
||||
});
|
||||
if (removed.code !== 0 || removed.spawnError || removed.timedOut) {
|
||||
if (removed.timedOut || removed.spawnError) return gitFailure(removed, 'removing the worktree');
|
||||
const text = gitRefusalText(removed.stderr);
|
||||
// git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is
|
||||
// the answer, in git's words.
|
||||
return fail(
|
||||
'CONFLICT',
|
||||
text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.',
|
||||
text
|
||||
);
|
||||
}
|
||||
const branch = found.worktree.branch;
|
||||
let branchDeleted = false;
|
||||
let branchError: string | undefined;
|
||||
if (req.deleteBranch && branch) {
|
||||
const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
|
||||
if (del.code === 0) branchDeleted = true;
|
||||
else branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.';
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
removed: {
|
||||
name: req.name,
|
||||
path: found.path,
|
||||
branch,
|
||||
branchDeleted,
|
||||
...(branchError ? { branchError } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @fileoverview Capability tokens for rendering a local HTML file.
|
||||
*
|
||||
* `file-raw` never serves HTML as HTML: a page rendered from Codeman's own origin
|
||||
* could read the Codeman document and drive the agent-spawning API. So an HTML
|
||||
* file is rendered from `/html-view/:cap/*` instead, with a `Content-Security-
|
||||
* Policy: sandbox` header that puts the document in an OPAQUE origin no matter how
|
||||
* it is opened (iframe or top-level tab). That opaque origin is also why the route
|
||||
* cannot authenticate on the session cookie: its subresource requests are
|
||||
* cross-site, so the `SameSite=lax` cookie is never attached (the same reason the
|
||||
* web-tab proxy uses capabilities, see webview-capabilities.ts).
|
||||
*
|
||||
* A capability is minted by an authenticated `POST /api/sessions/:id/html-view` and
|
||||
* grants exactly one thing: GET access to the files in ONE directory (the HTML
|
||||
* file's own), so a page's relative `style.css` / `chart.js` / images resolve.
|
||||
*
|
||||
* - 192 bits of `randomBytes` entropy, base64url.
|
||||
* - Held in memory only. A restart invalidates every outstanding capability.
|
||||
* - Rolling TTL, revoked on logout, admin logout and user deletion (`revokeOwner`).
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { StaleExpirationMap } from './utils/index.js';
|
||||
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
||||
|
||||
/** URL prefix the rendered files are served under. */
|
||||
export const HTML_VIEW_PREFIX = '/html-view';
|
||||
|
||||
export interface HtmlViewCapabilityRecord {
|
||||
/** Realpath of the directory the capability serves. */
|
||||
rootDir: string;
|
||||
/** Username that minted it (multi-user); undefined in single-user mode. */
|
||||
owner?: string;
|
||||
/**
|
||||
* Whether the directory lies outside the minting session's workspace (admitted
|
||||
* under the attachment guard). Recorded so the serving route can refuse it the
|
||||
* moment workspace confinement is switched on, as the attachment routes do.
|
||||
*/
|
||||
outsideWorkspace: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class HtmlViewCapabilityStore {
|
||||
private readonly capabilities: StaleExpirationMap<string, HtmlViewCapabilityRecord>;
|
||||
/** Reverse index so re-opening a page reuses its capability instead of leaking one per click. */
|
||||
private readonly byRoot = new Map<string, string>();
|
||||
|
||||
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
||||
this.capabilities = new StaleExpirationMap<string, HtmlViewCapabilityRecord>({
|
||||
ttlMs,
|
||||
refreshOnGet: true,
|
||||
onExpire: (token, record) => {
|
||||
const key = rootKey(record.rootDir, record.owner, record.outsideWorkspace);
|
||||
if (this.byRoot.get(key) === token) this.byRoot.delete(key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mint (or reuse) a capability for a directory. Returns the token. */
|
||||
mint(rootDir: string, owner?: string, outsideWorkspace = false): string {
|
||||
// The flag is part of the key: an out-of-workspace mint must never reuse a
|
||||
// capability recorded as in-workspace, which confinement would not stop.
|
||||
const key = rootKey(rootDir, owner, outsideWorkspace);
|
||||
const existing = this.byRoot.get(key);
|
||||
if (existing && this.capabilities.get(existing)) return existing;
|
||||
if (existing) this.byRoot.delete(key);
|
||||
|
||||
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
||||
|
||||
const token = randomBytes(24).toString('base64url');
|
||||
this.capabilities.set(token, { rootDir, owner, outsideWorkspace, createdAt: Date.now() });
|
||||
this.byRoot.set(key, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
||||
resolve(token: string): HtmlViewCapabilityRecord | undefined {
|
||||
if (!token) return undefined;
|
||||
return this.capabilities.get(token);
|
||||
}
|
||||
|
||||
/** Revoke every capability bound to an identity (logout, admin logout, user deletion). */
|
||||
revokeOwner(owner: string | undefined): number {
|
||||
let revoked = 0;
|
||||
for (const [key, token] of [...this.byRoot]) {
|
||||
const record = this.capabilities.peek(token);
|
||||
if (!record || record.owner === owner) {
|
||||
this.capabilities.delete(token);
|
||||
this.byRoot.delete(key);
|
||||
if (record) revoked++;
|
||||
}
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.capabilities.size;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.capabilities.dispose();
|
||||
this.byRoot.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function rootKey(rootDir: string, owner: string | undefined, outsideWorkspace: boolean): string {
|
||||
return `${owner ?? ''}\0${outsideWorkspace ? 'out' : 'in'}\0${rootDir}`;
|
||||
}
|
||||
|
||||
/** The capability token in an `/html-view/<cap>/...` path, or null. */
|
||||
export function capabilityFromHtmlViewPath(pathname: string): string | null {
|
||||
if (typeof pathname !== 'string') return null;
|
||||
const prefix = `${HTML_VIEW_PREFIX}/`;
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
const rest = pathname.slice(prefix.length);
|
||||
const slash = rest.indexOf('/');
|
||||
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
||||
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
||||
}
|
||||
|
||||
/** Process-wide store, shared by the routes and the auth middleware (see webviewCapabilities). */
|
||||
export const htmlViewCapabilities = new HtmlViewCapabilityStore();
|
||||
@@ -40,7 +40,7 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join } from 'node:path';
|
||||
import { basename, dirname, isAbsolute, join } from 'node:path';
|
||||
import { parse as parseToml, TomlError } from 'smol-toml';
|
||||
import type { McpConfigFormat } from './config/cli-registry/types.js';
|
||||
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
|
||||
@@ -66,6 +66,11 @@ export interface McpSyncTarget {
|
||||
label: string;
|
||||
/** Home-relative default location of the config file. */
|
||||
path: string;
|
||||
/**
|
||||
* Home-relative fallback file used when `path` is absent: read, and written in place when it
|
||||
* parses (a JSONC file with comments fails the parse and is never written).
|
||||
*/
|
||||
altPath?: string;
|
||||
format: McpFormat;
|
||||
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
|
||||
relocation?: { envVar: string; path: string };
|
||||
@@ -197,11 +202,12 @@ function toClaude(s: McpServer): Record<string, unknown> {
|
||||
function fromGemini(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
|
||||
// today, and a bare `url` with no type is the legacy SSE form.
|
||||
// today. A bare `url` with no type used to be the legacy SSE form, but gemini-cli v0.63.0 tries
|
||||
// it as streamable HTTP first (and logs `httpUrl` as deprecated), so map it to 'http'.
|
||||
if (typeof raw.httpUrl === 'string')
|
||||
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
|
||||
if (typeof raw.url === 'string') {
|
||||
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
|
||||
return clean({ transport: raw.type === 'sse' ? 'sse' : 'http', url: raw.url, headers: strMap(raw.headers) });
|
||||
}
|
||||
if (typeof raw.command === 'string') {
|
||||
return clean({
|
||||
@@ -255,7 +261,7 @@ function toAntigravity(s: McpServer): Record<string, unknown> | null {
|
||||
|
||||
function fromOpencode(raw: unknown): McpServer | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const disabled = raw.enabled === false;
|
||||
const disabled = raw.enabled === false || raw.disabled === true;
|
||||
if (raw.type === 'remote' && typeof raw.url === 'string') {
|
||||
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
|
||||
}
|
||||
@@ -311,6 +317,8 @@ function toCopilot(s: McpServer): Record<string, unknown> {
|
||||
interface JsonDialect {
|
||||
/** Key holding the server table. */
|
||||
key: string;
|
||||
/** Servers also read from under `key.nestedKey` (OpenCode V2 nests them under `mcp.servers`). */
|
||||
nestedKey?: string;
|
||||
from(raw: unknown): McpServer | null;
|
||||
to(s: McpServer): Record<string, unknown> | null;
|
||||
/** Top-level keys to seed when creating the file from nothing. */
|
||||
@@ -334,6 +342,7 @@ const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
|
||||
},
|
||||
'opencode-json': {
|
||||
key: 'mcp',
|
||||
nestedKey: 'servers',
|
||||
from: fromOpencode,
|
||||
to: toOpencode,
|
||||
seed: { $schema: 'https://opencode.ai/config.json' },
|
||||
@@ -405,7 +414,17 @@ function mcpTable(format: McpFormat, text: string | null): Record<string, unknow
|
||||
const table = doc[dialect.key];
|
||||
if (table === undefined) return dict<unknown>();
|
||||
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
|
||||
return table;
|
||||
if (!dialect.nestedKey) return table;
|
||||
const nested = table[dialect.nestedKey];
|
||||
// OpenCode reads `mcp.servers` as a V1 server named "servers" when it carries a V1 `type`.
|
||||
if (nested === undefined || (isRecord(nested) && (nested.type === 'local' || nested.type === 'remote'))) return table;
|
||||
if (!isRecord(nested)) throw new McpConfigError(`"${dialect.key}.${dialect.nestedKey}" is not an object`);
|
||||
// Merge the direct (V1) table with the nested (V2) one. Like OpenCode, the native V2 entry wins a
|
||||
// name collision; sync never writes a name already defined, so this does not affect the read-back.
|
||||
const merged = dict<unknown>();
|
||||
for (const k of safeKeys(table)) if (k !== dialect.nestedKey) merged[k] = table[k];
|
||||
for (const k of safeKeys(nested)) merged[k] = nested[k];
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
|
||||
@@ -593,6 +612,30 @@ function resolveFile(
|
||||
return { file: join(dir, rel.path) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The alternate config file for a target (`McpSyncTarget.altPath`), honouring the same relocation
|
||||
* as the primary file: the alt lives in the same directory, so the relocated path is the primary's
|
||||
* directory plus the alt filename. Once chosen it is read and written exactly like the primary; a
|
||||
* JSONC file with comments fails the parse guard and is never written.
|
||||
*/
|
||||
function resolveAltFile(
|
||||
t: McpSyncTarget,
|
||||
altPath: string,
|
||||
home: string,
|
||||
env: Record<string, string | undefined>
|
||||
): { file: string; skip?: string } {
|
||||
const rel = t.relocation;
|
||||
const dir = rel ? env[rel.envVar] : undefined;
|
||||
if (!rel || dir === undefined || dir === '') return { file: join(home, altPath) };
|
||||
if (!isAbsolute(dir)) {
|
||||
return {
|
||||
file: `$${rel.envVar}/…`,
|
||||
skip: `${rel.envVar} is set to a relative path, so the alt file cannot be located safely`,
|
||||
};
|
||||
}
|
||||
return { file: join(dir, dirname(rel.path), basename(altPath)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
|
||||
* disabled, so they are not copied. If that file cannot be read as intended the target is
|
||||
@@ -667,6 +710,15 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
|
||||
for (const s of state) {
|
||||
if (s.res.status !== 'ok') continue;
|
||||
try {
|
||||
// A config kept under an alternate name (OpenCode's opencode.jsonc) is read when the primary
|
||||
// file is absent, so its servers are synced rather than silently treated as "not installed".
|
||||
if (s.t.altPath && !(await exists(s.file))) {
|
||||
const alt = resolveAltFile(s.t, s.t.altPath, home, env);
|
||||
if (!alt.skip && (await exists(alt.file))) {
|
||||
s.file = alt.file;
|
||||
s.res.file = alt.file;
|
||||
}
|
||||
}
|
||||
if (!s.t.installed && !(await exists(s.file))) {
|
||||
s.res.status = 'absent';
|
||||
continue;
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
PaneExit,
|
||||
StatusLineExporter,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -57,6 +58,8 @@ export interface MuxSession {
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
|
||||
statusLine?: StatusLineExporter;
|
||||
/**
|
||||
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||
@@ -189,6 +192,21 @@ export interface PaneCaptureOptions {
|
||||
fullHistory?: boolean;
|
||||
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
||||
historyLimitLines?: number;
|
||||
/**
|
||||
* Full-history only: scrollback rows read from somewhere other than this pane
|
||||
* (raw `capture-pane -p -e -J` text, oldest first). The pane then contributes
|
||||
* only its visible frame and caret, and its own history is not read. A remote
|
||||
* session's real history lives in the REMOTE tmux (`remote-pane-history.ts`).
|
||||
*/
|
||||
scrollbackOverride?: string;
|
||||
/**
|
||||
* With `scrollbackOverride`: the screen rows of the pane that history came
|
||||
* from, read right after it. The frame this pane captures later can have
|
||||
* scrolled past some of them, or show only part of a taller screen, so the
|
||||
* rows above where the frame's top overlaps them are kept between the
|
||||
* scrollback and the frame (`joinScrollbackAndFrame`).
|
||||
*/
|
||||
scrollbackOverrideScreen?: string;
|
||||
/**
|
||||
* Byte cap the consumer will keep from the capture. Sizes the child-process
|
||||
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
|
||||
@@ -204,6 +222,24 @@ export interface PaneCaptureOptions {
|
||||
* rendering it needs the real height to know the frame fits.
|
||||
*/
|
||||
capturedGeometry?: { cols: number; rows: number };
|
||||
/**
|
||||
* Filled in by the implementation with the number of rows the pane holds in
|
||||
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
|
||||
* same query as the geometry. 0 means a full-history capture can return
|
||||
* nothing beyond the visible frame: a pane in the alternate screen (a
|
||||
* fullscreen CLI that keeps its transcript itself) never accumulates any.
|
||||
* A full-history capture bounded by `historyLimitLines` below this depth
|
||||
* left older lines behind in the multiplexer, which the caller must report
|
||||
* as recoverable. Absent when the pane could not be queried.
|
||||
*/
|
||||
capturedHistoryLines?: number;
|
||||
/**
|
||||
* Filled in by the implementation with whether the pane was in the alternate
|
||||
* screen at capture time, when it can tell. tmux keeps no history for such a
|
||||
* pane (a nested tmux client puts it there), so a full-history capture of it
|
||||
* holds one screen plus whatever the pane held before.
|
||||
*/
|
||||
capturedAlternateOn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
|
||||
* `echo` (which land before the script runs) from the records without any "last N
|
||||
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
|
||||
*
|
||||
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
|
||||
* server does not know that home, and a clicked `~/…` link means the host the
|
||||
* session runs on. The record's `realPath` is absolute, as for any other path.
|
||||
*
|
||||
* Symlink resolution is portable AND fails closed. `readlink -f` where available
|
||||
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
|
||||
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
|
||||
@@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string {
|
||||
'probe() {',
|
||||
' i=$1',
|
||||
' p=$2',
|
||||
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
|
||||
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
|
||||
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
|
||||
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
@@ -269,6 +275,126 @@ export async function remoteProbePaths(
|
||||
return results;
|
||||
}
|
||||
|
||||
/** One entry of a remote folder listing: a file (with size/mtime) or a subfolder. */
|
||||
export interface RemoteFolderEntry {
|
||||
kind: 'file' | 'directory';
|
||||
name: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/** A remote folder listing; `dir` is the folder with symlinks resolved on the remote host. */
|
||||
export interface RemoteFolderListing {
|
||||
dir: string;
|
||||
entries: RemoteFolderEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List one remote folder in a SINGLE ssh round trip: its canonical path, then every
|
||||
* non-hidden subfolder and every regular file (symlinks followed) whose name ends in
|
||||
* one of `extensions` (any case), up to `maxEntries`.
|
||||
*
|
||||
* Same output discipline as {@link buildRemoteProbeCommand}: a lone NUL first (so rc
|
||||
* chatter before it is discarded), then NUL-terminated records — `E|n` (no such
|
||||
* folder), `E|x` (cannot be canonicalized), `D|<dir>`, `d|0|0|<name>`,
|
||||
* `f|<size>|<mtime>|<name>`, and `T|` when the cap cut the listing. The name is the
|
||||
* last field, so a `|` in it still parses, and NUL is the one byte a filename cannot
|
||||
* hold. A leading `~/` is the remote user's `$HOME`, as in the probe.
|
||||
*/
|
||||
export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string {
|
||||
// `[pP][nN][gG]`: any case, as the local listing compares lowercased (`shot.Png`).
|
||||
const patterns = [...new Set(extensions.map((ext) => ext.toLowerCase()))]
|
||||
.map((ext) => `*.${ext.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`)}`)
|
||||
.join('|');
|
||||
const script = [
|
||||
`d=${shellescape(dir)}`,
|
||||
"case $d in '~/'*) d=$HOME/${d#??} ;; esac",
|
||||
"printf '\\0'",
|
||||
'[ -d "$d" ] || { [ -e "$d" ] && printf \'E|f\\0\' || printf \'E|n\\0\'; exit 0; }',
|
||||
'r=$(cd -P "$d" 2>/dev/null && pwd -P) || { printf \'E|x\\0\'; exit 0; }',
|
||||
'printf \'D|%s\\0\' "$r"',
|
||||
'n=0',
|
||||
'for f in "$r"/*; do',
|
||||
' [ -e "$f" ] || continue',
|
||||
` [ "$n" -lt ${Math.max(1, Math.floor(maxEntries))} ] || { printf 'T|\\0'; break; }`,
|
||||
' b=${f##*/}',
|
||||
' if [ -d "$f" ]; then n=$((n + 1)); printf \'d|0|0|%s\\0\' "$b"; continue; fi',
|
||||
' [ -f "$f" ] || continue',
|
||||
` case $b in ${patterns || '*.__none__'}) ;; *) continue ;; esac`,
|
||||
" sm=$(stat -L -c '%s %Y' \"$f\" 2>/dev/null || stat -L -f '%z %m' \"$f\" 2>/dev/null) || sm='0 0'",
|
||||
' n=$((n + 1))',
|
||||
' printf \'f|%s|%s|%s\\0\' "${sm% *}" "${sm#* }" "$b"',
|
||||
'done',
|
||||
].join('\n');
|
||||
// ⚠️ sshd runs the command in the remote user's LOGIN shell, rc aliases included.
|
||||
// On Hufflepuff `cd` is an alias that echoes the new directory (`$(cd -P "$d" &&
|
||||
// pwd -P)` came back as two lines, then zsh aborted on the glob: "no matches found:
|
||||
// /x\n/x/*"), so no folder listing worked over ssh. An explicit `sh` reads no rc
|
||||
// file — the same wrapper the transcript read uses.
|
||||
return `sh -c ${shellescape(script)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@link buildRemoteFolderListCommand} output: null when nothing exists there
|
||||
* (or it cannot be resolved), `'not-a-folder'` when it is a file.
|
||||
*/
|
||||
export function parseRemoteFolderListOutput(stdout: string): RemoteFolderListing | 'not-a-folder' | null {
|
||||
const records = stdout.split('\0').slice(1);
|
||||
let dir: string | null = null;
|
||||
let truncated = false;
|
||||
const entries: RemoteFolderEntry[] = [];
|
||||
for (const record of records) {
|
||||
if (record === 'E|f') return 'not-a-folder';
|
||||
if (record.startsWith('E|')) return null;
|
||||
if (record.startsWith('D|')) {
|
||||
dir = record.slice(2);
|
||||
continue;
|
||||
}
|
||||
if (record === 'T|') {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
const match = /^([fd])\|(\d*)\|(\d*)\|([\s\S]+)$/.exec(record);
|
||||
if (!match) continue;
|
||||
const size = Number.parseInt(match[2], 10);
|
||||
const mtime = Number.parseInt(match[3], 10);
|
||||
entries.push({
|
||||
kind: match[1] === 'd' ? 'directory' : 'file',
|
||||
name: match[4],
|
||||
size: Number.isFinite(size) && size > 0 ? size : 0,
|
||||
mtimeMs: Number.isFinite(mtime) && mtime > 0 ? mtime * 1000 : 0,
|
||||
});
|
||||
}
|
||||
if (!dir) {
|
||||
throw new RemoteFileAccessError('remote host returned no usable folder listing');
|
||||
}
|
||||
return { dir, entries, truncated };
|
||||
}
|
||||
|
||||
/** List a remote folder (see {@link buildRemoteFolderListCommand}). */
|
||||
export async function remoteListFolder(
|
||||
remote: SessionRemote,
|
||||
dir: string,
|
||||
extensions: readonly string[],
|
||||
maxEntries: number
|
||||
): Promise<RemoteFolderListing | 'not-a-folder' | null> {
|
||||
assertNotUnderTest();
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteFolderListCommand(dir, extensions, maxEntries));
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await runWithRemoteSshLimit(() =>
|
||||
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 })
|
||||
);
|
||||
stdout = result.stdout;
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(
|
||||
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
||||
);
|
||||
}
|
||||
return parseRemoteFolderListOutput(stdout);
|
||||
}
|
||||
|
||||
/** Read a whole remote file into memory, capped by `maxBytes`. */
|
||||
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
|
||||
assertNotUnderTest();
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @fileoverview Scrollback of a remote SSH session, read from the REMOTE tmux.
|
||||
*
|
||||
* A remote session's local pane runs `ssh -t … tmux new-session -A` (or `attach`),
|
||||
* so the local tmux only ever sees what the remote tmux CLIENT draws: full-screen
|
||||
* repaints, not scrolling output. The local pane therefore keeps a fraction of the
|
||||
* history (measured: 48 of 279 lines while output streamed, none after a reattach),
|
||||
* while the remote pane, where the shell really runs, keeps all of it. A full-history
|
||||
* load of a remote session reads the scrollback from there instead, and the caller
|
||||
* splices it above the LOCAL visible frame (`PaneCaptureOptions.scrollbackOverride`):
|
||||
* the local pane is what the live stream keeps drawing into, so its frame and caret
|
||||
* position are the ones the browser must end up with.
|
||||
*
|
||||
* ONE ssh round trip reads the remote pane's scrollback rows (`capture-pane -S -<N>
|
||||
* -E -1`), its `#{history_size}` and its screen. The size is load-bearing: with no
|
||||
* history at all, tmux clamps `-E -1` to the first VISIBLE row and would return it
|
||||
* as history. The screen closes the seam between the remote read and the local
|
||||
* frame, which is read only after the round trip: rows that scrolled in between,
|
||||
* and the top of a remote screen taller than the local pane, are on that screen
|
||||
* alone, and the caller keeps the screen rows above where the local frame overlaps
|
||||
* it (`joinScrollbackAndFrame`).
|
||||
*
|
||||
* ⚠️ The three reads are ONE tmux command list (`a \; b \; c`), not three tmux
|
||||
* runs. tmux runs a client's command list back to back without reading pane output
|
||||
* in between, so the history ends exactly where the screen begins. As separate runs
|
||||
* the shell kept writing between them, and the rows that scrolled in those
|
||||
* milliseconds were in neither capture (measured on a busy pane: every read lost
|
||||
* rows). This rests on how tmux's command queue behaves, not on a documented
|
||||
* guarantee. tmux output cannot carry the NUL markers, so a random marker line
|
||||
* printed by `display-message` separates the history from the size and screen.
|
||||
* tmux before 3.2 prints no newline after `capture-pane -p`, so the marker can be
|
||||
* glued onto an unterminated last history row; the parser accepts it there too.
|
||||
*
|
||||
* Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd
|
||||
* output) returns null and the caller keeps the local capture, so a remote load is
|
||||
* never worse than before. A transport failure also starts a short per-host back-off,
|
||||
* so a host that is asleep costs one timeout, not one per tab switch. The child runs
|
||||
* under the shared remote-ssh limiter (`remote-ssh-limiter.ts`) like every other
|
||||
* short-lived remote ssh, but gives up on a slot it waited too long for (`maxWaitMs`,
|
||||
* enforced in the queue), and the connection options come from
|
||||
* `buildSshConnectionArgs()`.
|
||||
*
|
||||
* How much history exists is the REMOTE tmux's `history-limit` (2000 lines unless
|
||||
* the remote user's tmux.conf raises it); the launch command does not set it.
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||
import { remoteTmuxLocation } from './tmux-manager.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Bound on the whole round trip. A full load waits on it, so it is far below the
|
||||
* 10 s `ConnectTimeout` the shared ssh options carry: past this the local capture
|
||||
* is the better answer.
|
||||
*/
|
||||
const REMOTE_HISTORY_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Longest a load waits for a slot in the shared ssh pool, which file previews and
|
||||
* attachment probes (20 s each) also hold. Past it the local capture is used.
|
||||
*/
|
||||
const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500;
|
||||
|
||||
/** After a failed read, skip that host for this long and use the local capture. */
|
||||
const REMOTE_HISTORY_BACKOFF_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Headroom over the remote `tail -c` bound for what it does not cover: the two NUL
|
||||
* markers and anything a login banner or rc file prints before the script.
|
||||
*/
|
||||
const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Added to the configured byte cap for the remote `tail -c` bound. That bound
|
||||
* covers the whole tmux output, and the marker, the size and the screen come last
|
||||
* (a wide, tall screen of colored rows runs to a few hundred KB); without this, a
|
||||
* small cap would cut into the screen and lose the read. The route trims what it
|
||||
* returns to the configured cap anyway, keeping the newest bytes.
|
||||
*/
|
||||
const REMOTE_SCREEN_ALLOWANCE_BYTES = 1024 * 1024;
|
||||
|
||||
/** The remote `tail -c` bound for a configured byte cap; 0 (unbounded) stays 0. */
|
||||
export function remoteTailBytes(maxBytes: number): number {
|
||||
return maxBytes > 0 ? Math.trunc(maxBytes) + REMOTE_SCREEN_ALLOWANCE_BYTES : 0;
|
||||
}
|
||||
|
||||
/** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */
|
||||
const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Host id → time the back-off ends. Keyed by HOST, so a sleeping host with five
|
||||
* sessions costs one timeout, and the map is bounded by the configured hosts.
|
||||
*/
|
||||
const backoffUntil = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* A fresh marker line for one read. Random, so no row the pane really holds can be
|
||||
* taken for it.
|
||||
*/
|
||||
export function newScrollbackMarker(): string {
|
||||
return `codeman-history-${randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote shell script. It prints a NUL, the tmux output, then a second NUL:
|
||||
* the first separates a login banner or an rc-file `echo` printed before the
|
||||
* script runs without guessing (the same marker `buildRemoteProbeCommand` uses),
|
||||
* and both are printed whether or not tmux succeeds, so their presence says the
|
||||
* script ran on the host. The tmux output is the history rows, `marker`, the
|
||||
* history size, then the screen rows.
|
||||
*/
|
||||
export function buildRemoteScrollbackScript(
|
||||
socket: string,
|
||||
sessionName: string,
|
||||
historyLines: number,
|
||||
tailBytes: number,
|
||||
marker: string
|
||||
): string {
|
||||
// `=name:` is an EXACT session match, then its active pane. A bare name is
|
||||
// read as a pane index or window name first, so a discovered session named
|
||||
// `0` would capture some other session's pane.
|
||||
const target = shellescape(`=${sessionName}:`);
|
||||
const lines = Math.max(1, Math.trunc(historyLines));
|
||||
// The newest rows are the ones worth keeping, so the byte bound is applied on
|
||||
// the remote side (`tail -c`) rather than by killing the read at maxBuffer.
|
||||
// It covers the whole tmux output, history first, so with the screen
|
||||
// allowance in `remoteTailBytes()` a cut lands in the history. 0 means
|
||||
// unbounded, as in the route.
|
||||
const bound = tailBytes > 0 ? ` | tail -c ${Math.trunc(tailBytes)}` : '';
|
||||
// ONE tmux command list, so no pane output is read between the history and
|
||||
// the screen (see the @fileoverview). `\;` reaches tmux as a lone `;`.
|
||||
return (
|
||||
`printf '\\0'; tmux -L ${shellescape(socket)} ` +
|
||||
`capture-pane -p -e -J -S -${lines} -E -1 -t ${target} \\; ` +
|
||||
`display-message -p -t ${target} ${shellescape(marker)} \\; ` +
|
||||
`display-message -p -t ${target} '#{history_size}' \\; ` +
|
||||
`capture-pane -p -e -J -t ${target}${bound}; printf '\\0'`
|
||||
);
|
||||
}
|
||||
|
||||
/** The full ssh command line for {@link buildRemoteScrollbackScript}. */
|
||||
export function buildRemoteScrollbackCommand(
|
||||
remote: SessionRemote,
|
||||
sessionId: string,
|
||||
historyLines: number,
|
||||
tailBytes: number,
|
||||
marker: string
|
||||
): string {
|
||||
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
|
||||
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker);
|
||||
// `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`.
|
||||
return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' ');
|
||||
}
|
||||
|
||||
/** What one round trip read from the remote pane: its scrollback rows, then its screen. */
|
||||
export interface RemoteScrollback {
|
||||
/** History rows above the screen, oldest first; '' when the pane has none. */
|
||||
scrollback: string;
|
||||
/** The screen's rows, read in the same tmux command list, so they begin where the scrollback ends. */
|
||||
screen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrollback and screen rows from the script's stdout, or null when the
|
||||
* output is not the script's or tmux printed nothing usable (no such session,
|
||||
* or a byte cap so small it cut into the screen). The marker may end an
|
||||
* unterminated history row (tmux before 3.2); when the output reached
|
||||
* `tailBytes`, the history's partial first row is dropped.
|
||||
*/
|
||||
export function parseRemoteScrollbackOutput(
|
||||
stdout: string,
|
||||
marker: string,
|
||||
tailBytes: number
|
||||
): RemoteScrollback | null {
|
||||
const start = stdout.indexOf('\0');
|
||||
if (start === -1) return null;
|
||||
const end = stdout.indexOf('\0', start + 1);
|
||||
if (end === -1) return null;
|
||||
// The marker ends a row, after the history's last row. Usually it is a row of
|
||||
// its own, but tmux before 3.2 prints no newline after `capture-pane -p`, so
|
||||
// when the history ends inside a wrapped line (`-J` leaves it unterminated)
|
||||
// the marker is glued onto that row. It is random per read, so no row the
|
||||
// pane really holds ends with it.
|
||||
const markerRow = `${marker}\n`;
|
||||
const raw = stdout.slice(start + 1, end);
|
||||
const at = raw.indexOf(markerRow);
|
||||
if (at === -1) return null;
|
||||
const sizeStart = at + markerRow.length;
|
||||
const sizeEnd = raw.indexOf('\n', sizeStart);
|
||||
if (sizeEnd === -1) return null;
|
||||
const sizeText = raw.slice(sizeStart, sizeEnd).trim();
|
||||
if (!/^\d+$/.test(sizeText)) return null;
|
||||
const screen = raw.slice(sizeEnd + 1);
|
||||
// With no history at all, tmux clamps `-E -1` to the first visible row.
|
||||
if (Number(sizeText) === 0) return { scrollback: '', screen };
|
||||
// A tail -c cut lands in the history (the bound leaves room for the rest),
|
||||
// mid-row and possibly mid-escape, so the first, partial row is dropped. A
|
||||
// cut exactly on the marker row leaves no history at all.
|
||||
let scrollback = raw.slice(0, at);
|
||||
if (tailBytes > 0 && Buffer.byteLength(raw, 'utf-8') >= tailBytes) {
|
||||
const newline = scrollback.indexOf('\n');
|
||||
scrollback = newline === -1 ? '' : scrollback.slice(newline + 1);
|
||||
}
|
||||
if (scrollback !== '' && !scrollback.endsWith('\n')) scrollback += '\n';
|
||||
return { scrollback, screen };
|
||||
}
|
||||
|
||||
/** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
|
||||
export type RemoteScrollbackRunner = (command: string, opts: { timeout: number; maxBuffer: number }) => Promise<string>;
|
||||
|
||||
const execRunner: RemoteScrollbackRunner = async (command, opts) =>
|
||||
(await execAsync(command, { encoding: 'utf-8', ...opts })).stdout;
|
||||
|
||||
/**
|
||||
* Read a remote session's scrollback from the remote tmux, or null to keep the
|
||||
* local capture. Never throws.
|
||||
*
|
||||
* Only a TRANSPORT failure backs the host off (ssh's own exit 255, a timeout, a
|
||||
* spawn error, output that is not the script's): those say the host is
|
||||
* unreachable for every session on it. A remote command failing (a session
|
||||
* that is gone: tmux answers nothing between the NULs, and the closing `printf`
|
||||
* hides its exit status) is about this session only, so it returns
|
||||
* null and the next session on that host still tries. Same split as
|
||||
* `classifyRemoteAliveExit()`.
|
||||
*
|
||||
* `run` is a test seam: without it nothing runs under vitest, as in
|
||||
* remote-files.ts / remote-hosts.ts.
|
||||
*/
|
||||
export async function fetchRemoteScrollback(
|
||||
remote: SessionRemote,
|
||||
sessionId: string,
|
||||
historyLines: number,
|
||||
maxBytes: number,
|
||||
run?: RemoteScrollbackRunner
|
||||
): Promise<RemoteScrollback | null> {
|
||||
if (!run && process.env.VITEST) return null;
|
||||
const runner = run ?? execRunner;
|
||||
const until = backoffUntil.get(remote.hostId);
|
||||
if (until !== undefined) {
|
||||
if (Date.now() < until) return null;
|
||||
backoffUntil.delete(remote.hostId);
|
||||
}
|
||||
const marker = newScrollbackMarker();
|
||||
const tailBytes = remoteTailBytes(maxBytes);
|
||||
const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, tailBytes, marker);
|
||||
try {
|
||||
const stdout = await runWithRemoteSshLimit(
|
||||
() =>
|
||||
runner(command, {
|
||||
timeout: REMOTE_HISTORY_TIMEOUT_MS,
|
||||
maxBuffer: (tailBytes > 0 ? tailBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES,
|
||||
}),
|
||||
// The pool is shared with 20 s file probes; past this the local capture
|
||||
// is the better answer than waiting behind them.
|
||||
{ maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS }
|
||||
);
|
||||
const parsed = parseRemoteScrollbackOutput(stdout, marker, tailBytes);
|
||||
if (parsed === null) {
|
||||
// Both NULs arrived, so the script ran on the host and only tmux had
|
||||
// nothing to say: no such session. The script's last command is the
|
||||
// closing `printf`, so this comes back as exit 0 rather than tmux's
|
||||
// exit 1, and it is about this session alone. Output without the NULs
|
||||
// is not the script's at all, which says the host is not answering as
|
||||
// expected.
|
||||
const parts = stdout.split('\0');
|
||||
const ranOnHost = parts.length >= 3;
|
||||
if (!ranOnHost) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
// tmux printed something, but not the marker, size and screen in order.
|
||||
const unreadable = ranOnHost && parts[1] !== '';
|
||||
const what = !ranOnHost
|
||||
? 'unexpected output'
|
||||
: unreadable
|
||||
? 'unreadable remote tmux output'
|
||||
: 'no such remote tmux session';
|
||||
console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
// A busy pool says nothing about the host.
|
||||
if (err instanceof RemoteSshSlotTimeoutError) return null;
|
||||
const e = err as { killed?: boolean; code?: unknown };
|
||||
const code = typeof e.code === 'number' ? e.code : null;
|
||||
const transport = classifyRemoteAliveExit(code, e.killed === true) === undefined;
|
||||
if (transport) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
const reason = e.killed ? 'timed out' : code !== null ? `exit ${code}` : 'failed';
|
||||
console.warn(`[remote-history] ${remote.label}: remote scrollback read ${reason}; using the local capture`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: forget every host back-off. */
|
||||
export function resetRemoteScrollbackBackoff(): void {
|
||||
backoffUntil.clear();
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Global concurrency limiter for the short-lived `ssh` children that
|
||||
* remote-case file access spawns (`src/remote-files.ts`: the realpath+stat probe and
|
||||
* the buffered text read).
|
||||
* the buffered text read). A remote session's full-history load reads its scrollback
|
||||
* from the remote tmux through the same pool (`src/remote-pane-history.ts`), with a
|
||||
* bounded wait (`maxWaitMs`) because it has the local capture to fall back on.
|
||||
*
|
||||
* Two paths can fan those out without a human behind each one:
|
||||
*
|
||||
@@ -60,12 +62,35 @@ export function getRemoteSshLimit(): number {
|
||||
return MAX_CONCURRENT_REMOTE_SSH;
|
||||
}
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
/** Thrown by {@link runWithRemoteSshLimit} when `maxWaitMs` passed before a slot freed up. */
|
||||
export class RemoteSshSlotTimeoutError extends Error {
|
||||
constructor(maxWaitMs: number) {
|
||||
super(`No remote ssh slot within ${maxWaitMs} ms`);
|
||||
this.name = 'RemoteSshSlotTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
function acquire(maxWaitMs?: number): Promise<void> {
|
||||
if (active < MAX_CONCURRENT_REMOTE_SSH) {
|
||||
active++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
if (maxWaitMs === undefined) return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
// A timed-out waiter leaves the queue and is never handed a slot, so it
|
||||
// owns nothing to give back: `active` is untouched on this path.
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index === -1) return; // already handed a slot by release()
|
||||
waiters.splice(index, 1);
|
||||
reject(new RemoteSshSlotTimeoutError(maxWaitMs));
|
||||
}, maxWaitMs);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
@@ -78,9 +103,14 @@ function release(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `task` once an ssh slot is free, releasing the slot afterward. */
|
||||
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>): Promise<T> {
|
||||
await acquire();
|
||||
/**
|
||||
* Run `task` once an ssh slot is free, releasing the slot afterward. With
|
||||
* `maxWaitMs`, give up instead (rejecting with {@link RemoteSshSlotTimeoutError},
|
||||
* `task` never runs) when no slot frees up in time: for a caller that has a
|
||||
* cheaper answer than waiting behind the queue.
|
||||
*/
|
||||
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>, opts: { maxWaitMs?: number } = {}): Promise<T> {
|
||||
await acquire(opts.maxWaitMs);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
|
||||
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
|
||||
if (value !== undefined) item[key] = value;
|
||||
}
|
||||
|
||||
/** `<repo>/.claude/worktrees/<name>`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */
|
||||
const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/;
|
||||
|
||||
/**
|
||||
* The worktree a working directory is, by its layout alone: `{ repo, name }` for
|
||||
* `<repo>/.claude/worktrees/<name>` (the folder itself, not a subfolder of it), else null.
|
||||
*/
|
||||
export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null {
|
||||
const m = WORKTREE_PATH.exec(workingDir);
|
||||
return m ? { repo: m[1], name: m[2] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all source views into one list, applying precedence
|
||||
* (history → lifecycle → persisted → live) and the meaningfulness floor.
|
||||
@@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
// A live or persisted session started in a worktree Codeman created (or Claude Code's own
|
||||
// `--worktree`) has no transcript record naming it until claude writes one, and a codex or
|
||||
// shell session never gets one, so its row read like any other session of the repo. The
|
||||
// folder layout says which worktree it is; a transcript record, when there is one, still wins.
|
||||
if (!item.worktreeName && item.workingDir) {
|
||||
const wt = worktreeFromPath(item.workingDir);
|
||||
if (wt) {
|
||||
item.worktreeName = wt.name;
|
||||
item.worktreeRepo ??= wt.repo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
type PaneExit,
|
||||
type PromptCacheStatus,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
@@ -585,6 +586,8 @@ export class Session extends EventEmitter {
|
||||
* running. A session that comes back with no label has no chip on its screen.
|
||||
*/
|
||||
private _watching: string | null = null;
|
||||
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
|
||||
private _promptCache: PromptCacheStatus | null = null;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||
@@ -873,6 +876,8 @@ export class Session extends EventEmitter {
|
||||
paneExit?: PaneExit;
|
||||
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
|
||||
displayModel?: DisplayModel;
|
||||
/** The previous run's last prompt-cache report, restored so the readout is not blank (see `promptCache`). */
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||
discoveredMuxSession?: boolean;
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
@@ -1041,6 +1046,11 @@ export class Session extends EventEmitter {
|
||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||
this.setPaneExit(config.paneExit);
|
||||
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
|
||||
// Last prompt-cache report, restored so the Session Options readout is not blank until
|
||||
// the pane's next statusline render. The readout is time-aware, so a value whose expiry
|
||||
// has passed renders as cold rather than stale-warm. Set directly, not via
|
||||
// setPromptCache, so recovery does not re-stamp a compaction it did not just observe.
|
||||
this._promptCache = config.promptCache ?? null;
|
||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||
// both the id and the saved parent come from disk.
|
||||
@@ -1244,6 +1254,11 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
if (newId === this._claudeSessionId) return;
|
||||
this._claudeSessionId = newId;
|
||||
// The conversation itself changed (a post-/clear switch), so the old prefix's cache
|
||||
// report and its compaction baseline belong to a conversation that is gone. Drop them
|
||||
// rather than show the previous conversation's "warm until" against the new one; the
|
||||
// next statusline render repopulates. A same-id reattach returned above, untouched.
|
||||
this._promptCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1414,6 +1429,46 @@ export class Session extends EventEmitter {
|
||||
return this._watching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt-cache state of the main conversation as Claude Code last reported it on the
|
||||
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Null until the first
|
||||
* report. It rides `toState()` for the Session Options readout and is restored on a
|
||||
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
|
||||
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
|
||||
* as cold rather than as a stale warm.
|
||||
*/
|
||||
get promptCache(): PromptCacheStatus | null {
|
||||
return this._promptCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
|
||||
* `compactedAt` when this report ENTERS the window in which Claude Code reads
|
||||
* `recacheTokensIfCold` as null: a compaction (`/compact` or its own idle compaction) or
|
||||
* a clearing of old tool results opened it, and the previous report still had a known
|
||||
* value. Claude Code closes the window on the next request (that request is the one that
|
||||
* counts as an expected rebuild), so `expectedRebuilds` never rises inside it and is not
|
||||
* read here. The stamp is carried while the window holds and dropped once it closes; a
|
||||
* first report already inside the window gets none. It is never an alert.
|
||||
*/
|
||||
setPromptCache(status: PromptCacheStatus): boolean {
|
||||
const prev = this._promptCache;
|
||||
const inWindow = status.warm && status.recacheTokensIfCold == null;
|
||||
let compactedAt: number | undefined;
|
||||
if (inWindow && prev?.recacheTokensIfCold != null) {
|
||||
compactedAt = Date.now();
|
||||
} else if (inWindow && prev?.compactedAt != null) {
|
||||
compactedAt = prev.compactedAt;
|
||||
}
|
||||
// A NEW object when a stamp applies, never a mutation of the caller's parsed report: the
|
||||
// route broadcasts the STORED object, so the stamp lives here and never leaks back into the
|
||||
// parser's output (which would make the route's "broadcast the stored object" choice vacuous).
|
||||
const next = compactedAt != null ? { ...status, compactedAt } : status;
|
||||
const changed = JSON.stringify(next) !== JSON.stringify(prev);
|
||||
this._promptCache = next;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
@@ -1893,6 +1948,8 @@ export class Session extends EventEmitter {
|
||||
autoCompactPrompt: this._autoOps.autoCompactPrompt,
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
promptCache: this._promptCache ?? undefined,
|
||||
statusLineExporter: this._muxSession?.statusLine,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
|
||||
@@ -32,29 +32,85 @@ export function applyLegacySessionRank(
|
||||
|
||||
const isRanked = (ref: TabRef): boolean =>
|
||||
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
|
||||
const prepare = (ref: TabRef): TabRef => {
|
||||
if (ref.kind !== 'session') return { ...ref };
|
||||
const item = sessionMetadata.get(ref.id);
|
||||
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
|
||||
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
|
||||
};
|
||||
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
|
||||
const ranked = refs
|
||||
.filter(isRanked)
|
||||
.map(prepare)
|
||||
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
||||
const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
|
||||
let rankedIndex = 0;
|
||||
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
|
||||
return refs.map((ref) => ({ ...(isRanked(ref) ? ranked[rankedIndex++] : ref) }));
|
||||
};
|
||||
|
||||
const transformed: TabLayout = {
|
||||
const ranked: TabLayout = {
|
||||
...layout,
|
||||
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
|
||||
ungrouped: rankContainer(layout.ungrouped),
|
||||
};
|
||||
const manual = childrenSplitFromParent(ranked, sessionMetadata);
|
||||
const pin = (ref: TabRef): TabRef =>
|
||||
ref.kind === 'session' && manual.has(ref.id) ? { ...ref, placement: 'manual' } : ref;
|
||||
const transformed: TabLayout = {
|
||||
...ranked,
|
||||
groups: ranked.groups.map((group) => ({ ...group, refs: group.refs.map(pin) })),
|
||||
ungrouped: ranked.ungrouped.map(pin),
|
||||
};
|
||||
return normalizeTabLayout(transformed, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* The following children (no `manual` placement, an owner-valid parent) that a
|
||||
* ranked layout separates from their parent's block. A parent's block is the
|
||||
* parent and, right after it in the same container, its following descendants;
|
||||
* sibling order inside the block is free, and a web tab the ranking left in a
|
||||
* slot between them does not split it (normalization closes the block again). Only these children become `manual`:
|
||||
* a PUT of the current order, or one that moves a family together, leaves every
|
||||
* stored placement alone. Pinning one child shrinks its parent's block, so the
|
||||
* check repeats until nothing more is pinned.
|
||||
*/
|
||||
function childrenSplitFromParent(ranked: TabLayout, sessionMetadata: ReadonlyMap<string, TabRefMetadata>): Set<string> {
|
||||
const containers = [...ranked.groups.map((group) => group.refs), ranked.ungrouped];
|
||||
const following = new Map<string, string>();
|
||||
for (const refs of containers) {
|
||||
for (const ref of refs) {
|
||||
if (ref.kind !== 'session' || ref.placement === 'manual') continue;
|
||||
const parentId = sessionMetadata.get(ref.id)?.parentSessionId;
|
||||
if (parentId && sessionMetadata.has(parentId)) following.set(ref.id, parentId);
|
||||
}
|
||||
}
|
||||
const descendsFrom = (id: string, ancestorId: string): boolean => {
|
||||
let current = following.get(id);
|
||||
for (let hops = 0; current !== undefined && hops <= following.size; hops++) {
|
||||
if (current === ancestorId) return true;
|
||||
current = following.get(current);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const manual = new Set<string>();
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const refs of containers) {
|
||||
const index = new Map<string, number>();
|
||||
refs.forEach((ref, at) => {
|
||||
if (ref.kind === 'session') index.set(ref.id, at);
|
||||
});
|
||||
refs.forEach((ref, at) => {
|
||||
if (ref.kind !== 'session') return;
|
||||
const parentId = following.get(ref.id);
|
||||
if (parentId === undefined) return;
|
||||
const parentAt = index.get(parentId);
|
||||
const inBlock =
|
||||
parentAt !== undefined &&
|
||||
parentAt < at &&
|
||||
refs
|
||||
.slice(parentAt + 1, at)
|
||||
.every((between) => between.kind !== 'session' || descendsFrom(between.id, parentId));
|
||||
if (inBlock) return;
|
||||
following.delete(ref.id);
|
||||
manual.add(ref.id);
|
||||
changed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
return manual;
|
||||
}
|
||||
|
||||
export function recomposeGlobalSessionOrder(
|
||||
current: readonly string[],
|
||||
projections: readonly OwnerOrderProjection[],
|
||||
|
||||
@@ -11,7 +11,9 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou
|
||||
import {
|
||||
flattenOwnerSessionOrder,
|
||||
materializeOrphans,
|
||||
moveRef,
|
||||
normalizeTabLayout,
|
||||
setManualPlacement,
|
||||
TabLayoutValidationError,
|
||||
validateTabLayout,
|
||||
type TabLayout,
|
||||
@@ -42,6 +44,12 @@ interface TabLayoutServiceDeps {
|
||||
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
|
||||
broadcast(event: string, data: unknown): void;
|
||||
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
|
||||
/**
|
||||
* The synced `spawnedTabsFollowParent` setting, read at every creation. Off (or
|
||||
* absent), a spawned session lands after its parent once and is hand-placed
|
||||
* from then on; on, it keeps following its parent until moved by hand.
|
||||
*/
|
||||
childrenFollowParent?(): boolean | Promise<boolean>;
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
@@ -67,6 +75,18 @@ export interface RemovedTabLayoutSession {
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */
|
||||
export interface SessionPlacementHint {
|
||||
tabGroupId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the created session to the live map. Runs INSIDE the owner lock, so no
|
||||
* other layout mutation can observe (and commit) the session first; the returned
|
||||
* function undoes it when the layout write fails.
|
||||
*/
|
||||
export type RegisterCreatedSession = () => void | (() => void);
|
||||
|
||||
interface PreparedOwnerLayout {
|
||||
current: TabLayout | null;
|
||||
authoritative: TabLayout;
|
||||
@@ -97,6 +117,66 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) ==
|
||||
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
|
||||
a.length === b.length && a.every((id, index) => id === b[index]);
|
||||
|
||||
/**
|
||||
* Hand-place a just-created child where normalization put it (right after its
|
||||
* parent), so it stops following. A session that does not follow anything is
|
||||
* left alone.
|
||||
*/
|
||||
function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
|
||||
const parentId = item?.parentSessionId;
|
||||
if (!item?.ownerValid || !parentId) return layout;
|
||||
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
|
||||
const ref = refs.find((candidate) => candidate.kind === 'session' && candidate.id === sessionId);
|
||||
const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId);
|
||||
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
|
||||
if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout;
|
||||
const pinned = setManualPlacement(layout, ref, true);
|
||||
// Normalization put the child right after its parent's FOLLOWING block, i.e.
|
||||
// ahead of the siblings pinned before it. Step past those (and their
|
||||
// descendants) so a fan-out keeps creation order.
|
||||
const parentOf = new Map<string, string>();
|
||||
for (const fact of metadata) {
|
||||
if (fact.kind === 'session' && fact.ownerValid && fact.parentSessionId) parentOf.set(fact.id, fact.parentSessionId);
|
||||
}
|
||||
const descendsFromParent = (id: string): boolean => {
|
||||
let current = parentOf.get(id);
|
||||
for (let hops = 0; current !== undefined && hops <= parentOf.size; hops++) {
|
||||
if (current === parentId) return true;
|
||||
current = parentOf.get(current);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isChild = (candidate: TabRef): boolean => candidate.kind === 'session' && candidate.id === sessionId;
|
||||
const group = pinned.groups.find((candidate) => candidate.refs.some(isChild));
|
||||
const container = group ? group.refs : pinned.ungrouped;
|
||||
const from = container.findIndex(isChild);
|
||||
let to = from;
|
||||
while (to + 1 < container.length && container[to + 1].kind === 'session' && descendsFromParent(container[to + 1].id))
|
||||
to++;
|
||||
if (to === from) return pinned;
|
||||
return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata);
|
||||
}
|
||||
|
||||
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
|
||||
function placeCreatedSession(
|
||||
layout: TabLayout,
|
||||
sessionId: string,
|
||||
groupId: string,
|
||||
metadata: readonly TabRefMetadata[]
|
||||
): TabLayout {
|
||||
const group = layout.groups.find((candidate) => candidate.id === groupId);
|
||||
if (!group) return layout;
|
||||
const index = group.refs.filter((ref) => ref.kind !== 'session' || ref.id !== sessionId).length;
|
||||
try {
|
||||
return moveRef(layout, { kind: 'session', id: sessionId }, { groupId, index }, metadata);
|
||||
} catch (error) {
|
||||
// Not an owner-valid ref of this layout: the hint does not apply.
|
||||
if (error instanceof TabLayoutValidationError) return layout;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class TabLayoutService {
|
||||
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
|
||||
private readonly ownerQueues = new Map<string, Promise<void>>();
|
||||
@@ -320,9 +400,13 @@ export class TabLayoutService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getUnlocked(owner: string): Promise<TabLayout> {
|
||||
private async getUnlocked(
|
||||
owner: string,
|
||||
adjust?: (layout: TabLayout, metadata: readonly TabRefMetadata[]) => TabLayout
|
||||
): Promise<TabLayout> {
|
||||
const prepared = await this.prepareUnlocked(owner);
|
||||
if (!prepared.needsReconciliationCommit) {
|
||||
const next = adjust ? adjust(prepared.authoritative, prepared.metadata) : prepared.authoritative;
|
||||
if (!prepared.needsReconciliationCommit && sameLayout(next, prepared.authoritative)) {
|
||||
const publication = {
|
||||
owner,
|
||||
previous: prepared.current,
|
||||
@@ -335,7 +419,7 @@ export class TabLayoutService {
|
||||
return prepared.authoritative;
|
||||
}
|
||||
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
|
||||
return this.commit(owner, base, prepared.authoritative, prepared.metadata);
|
||||
return this.commit(owner, base, next, prepared.metadata, prepared.current);
|
||||
}
|
||||
|
||||
async get(owner: string): Promise<TabLayout> {
|
||||
@@ -442,9 +526,46 @@ export class TabLayoutService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile one completed session creation into one versioned mutation. */
|
||||
async sessionCreated(owner: string): Promise<TabLayout> {
|
||||
return this.get(owner);
|
||||
/**
|
||||
* Reconcile one completed session creation into one versioned mutation.
|
||||
*
|
||||
* `placement.tabGroupId` puts the new session at the end of that group in the
|
||||
* same write. The group is looked up in THIS owner's layout only, so a group id
|
||||
* from another owner (or one deleted meanwhile) is simply not found and the
|
||||
* session keeps its normal placement: after its parent when it has one,
|
||||
* otherwise at the end of Ungrouped. An explicit group is a hand placement, so
|
||||
* a child session placed this way is marked `manual` and stops following.
|
||||
*
|
||||
* Whether a child placed by its lineage KEEPS following is the synced
|
||||
* `spawnedTabsFollowParent` setting, read here at creation: off (the default),
|
||||
* the child lands after its parent once and is marked `manual` in the same
|
||||
* write; on, it follows its parent until moved by hand. Flipping the setting
|
||||
* later leaves existing tabs as they are.
|
||||
*/
|
||||
async sessionCreated(
|
||||
owner: string,
|
||||
sessionId?: string,
|
||||
placement: SessionPlacementHint = {},
|
||||
register?: RegisterCreatedSession
|
||||
): Promise<TabLayout> {
|
||||
return this.withOwner(owner, async () => {
|
||||
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
|
||||
const rollback = register?.();
|
||||
try {
|
||||
const groupId = placement.tabGroupId;
|
||||
const adjust =
|
||||
sessionId && (groupId || !follow)
|
||||
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
|
||||
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
|
||||
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
|
||||
}
|
||||
: undefined;
|
||||
return await this.getUnlocked(owner, adjust);
|
||||
} catch (error) {
|
||||
if (typeof rollback === 'function') rollback();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Reconcile one completed saved-webview creation into one versioned mutation. */
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface TabRef {
|
||||
kind: TabRefKind;
|
||||
id: string;
|
||||
placement?: 'manual';
|
||||
/**
|
||||
* Pinned to the vertical rail's Focus section. The browser draws a SHORTCUT to
|
||||
* the ref there; the ref itself stays in its group, so the flag never moves
|
||||
* anything and nothing that moves a ref may drop it. Only `true` is stored:
|
||||
* an unfocused ref carries no key at all.
|
||||
*/
|
||||
focus?: true;
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
@@ -104,7 +111,13 @@ function parseRef(value: unknown, label: string): TabRef {
|
||||
if (value.placement !== undefined && value.placement !== 'manual') {
|
||||
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
|
||||
}
|
||||
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
|
||||
if (value.focus !== undefined && value.focus !== true) {
|
||||
throw new TabLayoutValidationError(`${label}.focus must be true when present`);
|
||||
}
|
||||
const ref: TabRef = { kind: value.kind, id };
|
||||
if (value.placement === 'manual') ref.placement = 'manual';
|
||||
if (value.focus === true) ref.focus = true;
|
||||
return ref;
|
||||
}
|
||||
|
||||
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
|
||||
@@ -395,6 +408,11 @@ export function setManualPlacement(input: TabLayout, target: TabRef, manual: boo
|
||||
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
|
||||
}
|
||||
|
||||
/** Pin `target` to the Focus section, or unpin it. Moves nothing; idempotent both ways. */
|
||||
export function setTabFocus(input: TabLayout, target: TabRef, focused: boolean): TabLayout {
|
||||
return mapRef(input, target, ({ focus: _focus, ...ref }) => (focused ? { ...ref, focus: true } : ref));
|
||||
}
|
||||
|
||||
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||
const normalized = normalizeTabLayout(input, metadata);
|
||||
if (target.kind !== 'session') {
|
||||
@@ -420,7 +438,8 @@ export function followParent(input: TabLayout, target: TabRef, metadata: readonl
|
||||
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
|
||||
}
|
||||
|
||||
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
|
||||
// Clear the manual placement only: a focused ref stays focused.
|
||||
const cleared = mapRef(normalized, target, ({ placement: _placement, ...ref }) => ref);
|
||||
return normalizeTabLayout(cleared, metadata);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
type PaneExit,
|
||||
type StatusLineExporter,
|
||||
} from './types.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
@@ -694,19 +695,126 @@ export function normalizeScrollbackEol(buffer: string): string {
|
||||
return buffer.replace(/\r?\n/g, '\r\n');
|
||||
}
|
||||
|
||||
/** `capture-pane -p` text as rows: tmux ends it with one `\n`, and '' has none. */
|
||||
function captureRows(text: string): string[] {
|
||||
return text === '' ? [] : text.replace(/\n$/, '').split('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Least overlap that places a frame in a screen: rows compared, and how many
|
||||
* of them hold text. A placement too LOW repeats the rows between the true one
|
||||
* and it, so a short overlap (one `$` or `❯` row, a rule line) is not trusted:
|
||||
* past this, the plain join, which can only lose rows, is the safer answer.
|
||||
*/
|
||||
const FRAME_MATCH_MIN_ROWS = 3;
|
||||
const FRAME_MATCH_MIN_CONTENT_ROWS = 2;
|
||||
|
||||
/**
|
||||
* A captured row as plain text: escape sequences (SGR, OSC 8 links) and
|
||||
* trailing blanks dropped. Two tmuxes render the same cell content, but need
|
||||
* not encode its attributes the same way.
|
||||
*/
|
||||
function capturedRowText(row: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return row.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g, '').trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a later frame of the same pane begins among the rows of an earlier
|
||||
* screen capture, or -1 when that cannot be told.
|
||||
*
|
||||
* The frame's top rows must equal the screen's rows from that index on, as
|
||||
* text. Two rows are left out of the comparison: the screen's trailing blank
|
||||
* rows (output after the screen read fills them in the frame) and its last
|
||||
* content row (output in flight may have finished it since). Every compared
|
||||
* row must match, the overlap must reach FRAME_MATCH_MIN_ROWS rows with
|
||||
* FRAME_MATCH_MIN_CONTENT_ROWS of them non-blank, and the SMALLEST index wins,
|
||||
* so a block that merely repeats further down is only chosen when nothing
|
||||
* above it matches. A row that changed between the two reads (a spinner, a
|
||||
* clock) fails the true placement; the minimum keeps a lower one from being
|
||||
* taken on a row or two that happen to be equal.
|
||||
*
|
||||
* An agent TUI mid-turn (Claude Code, codex, opencode) is not placed: it keeps
|
||||
* a live region (spinner, composer, footer) pinned to the bottom and inserts new
|
||||
* rows above it, so the frame is not a shifted copy of the screen and the
|
||||
* comparison down to the screen's last content row fails. The caller then falls
|
||||
* back to the plain join. A plain shell places, unless the frame starts on the
|
||||
* second half of a wrapped line: -J holds that line as one screen row, so it
|
||||
* matches nothing and the plain join applies too.
|
||||
*/
|
||||
export function findFrameInScreen(screenRows: string[], frameRows: string[]): number {
|
||||
const screen = screenRows.map(capturedRowText);
|
||||
const frame = frameRows.map(capturedRowText);
|
||||
let lastContent = screen.length - 1;
|
||||
while (lastContent >= 0 && screen[lastContent] === '') lastContent--;
|
||||
for (let start = 0; start < lastContent; start++) {
|
||||
const overlap = Math.min(lastContent - start, frame.length);
|
||||
if (overlap < FRAME_MATCH_MIN_ROWS) break; // only shrinks from here on
|
||||
let matches = true;
|
||||
let contentRows = 0;
|
||||
for (let k = 0; k < overlap && matches; k++) {
|
||||
if (screen[start + k] !== frame[k]) matches = false;
|
||||
else if (frame[k] !== '') contentRows++;
|
||||
}
|
||||
if (matches && contentRows >= FRAME_MATCH_MIN_CONTENT_ROWS) return start;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put scrollback rows read elsewhere (`PaneCaptureOptions.scrollbackOverride`)
|
||||
* above a visible-frame capture, as one capture in tmux's own shape: rows joined
|
||||
* by a bare `\n`. Undefined or empty scrollback leaves the frame as it is.
|
||||
*
|
||||
* `screen` is that other pane's screen, read right after its scrollback. The
|
||||
* frame is captured later, so rows can have scrolled out of it in between, and
|
||||
* a frame showing part of a taller screen starts below that screen's top.
|
||||
* Either way the screen rows above the frame's first row are in neither the
|
||||
* scrollback nor the frame, so they go between the two. When the frame cannot
|
||||
* be placed in the screen ({@link findFrameInScreen}), the join is the plain
|
||||
* scrollback-over-frame one.
|
||||
*/
|
||||
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string, screen?: string): string {
|
||||
let above = scrollback ? captureRows(scrollback) : [];
|
||||
if (screen) {
|
||||
const screenRows = captureRows(screen);
|
||||
const frameStart = findFrameInScreen(screenRows, captureRows(frame));
|
||||
if (frameStart > 0) {
|
||||
// The remote screen is a capture of its own too, encoded from the default
|
||||
// state: the history's last row must not leave its attributes active.
|
||||
if (above.length > 0) above[above.length - 1] += '\x1b[0m';
|
||||
above = above.concat(screenRows.slice(0, frameStart));
|
||||
}
|
||||
}
|
||||
// Separate captures: tmux encodes the frame relative to the DEFAULT state, so
|
||||
// the rows above it must not leave their attributes active.
|
||||
return above.length > 0 ? `${above.join('\n')}\x1b[0m\n${frame}` : frame;
|
||||
}
|
||||
|
||||
/** Pane geometry and caret position, as `display-message` reports them. */
|
||||
interface PaneCursorGeometry {
|
||||
cols: number;
|
||||
rows: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
/**
|
||||
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
|
||||
* full-history capture can add. Absent when the query did not return it.
|
||||
* Optional and validated on its own, so a bad value never voids the caret.
|
||||
*/
|
||||
historyLines?: number;
|
||||
/** Whether the pane is in the alternate screen (`#{alternate_on}`), when tmux reported it. */
|
||||
alternateOn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pane's cursor and size, or null when tmux cannot say.
|
||||
*
|
||||
* Every field is validated together: a caller that gets a value back can place
|
||||
* a caret with it, and one that gets null must not try.
|
||||
* The four geometry fields are validated together: a caller that gets a value
|
||||
* back can place a caret with it, and one that gets null must not try. The
|
||||
* optional fifth and sixth fields, the history depth and the alternate-screen
|
||||
* flag, are only informational, so a bad one is dropped rather than
|
||||
* discarding a good caret.
|
||||
*/
|
||||
export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
||||
let raw: string;
|
||||
@@ -716,7 +824,9 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
return null;
|
||||
}
|
||||
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
const [cursorX, cursorY, cols, rows, historyLines, alternateOn] = raw
|
||||
.split(/\s+/)
|
||||
.map((value) => parseInt(value, 10));
|
||||
if (
|
||||
!Number.isFinite(cursorX) ||
|
||||
!Number.isFinite(cursorY) ||
|
||||
@@ -729,7 +839,10 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { cols, rows, cursorX, cursorY };
|
||||
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
|
||||
if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines;
|
||||
if (alternateOn === 0 || alternateOn === 1) geometry.alternateOn = alternateOn === 1;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** SGR attributes, which is all `capture-pane -e` emits. */
|
||||
@@ -962,6 +1075,19 @@ export function remoteTmuxSessionName(sessionId: string): string {
|
||||
return `codeman-ssh-${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote tmux socket and session a remote Codeman session's pane is attached
|
||||
* to, matching the command that attached it: an owned session is the one
|
||||
* `buildRemoteLaunchCommand` created on the private socket, a non-owned one is the
|
||||
* discovered session `buildRemoteAttachCommand` joined on `-L codeman`.
|
||||
*/
|
||||
export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): { socket: string; sessionName: string } {
|
||||
if (remote.owned === false) {
|
||||
return { socket: 'codeman', sessionName: remote.remoteSessionName || remoteTmuxSessionName(sessionId) };
|
||||
}
|
||||
return { socket: REMOTE_TMUX_SOCKET, sessionName: remoteTmuxSessionName(sessionId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-104 — build the SSH command that launches (or reattaches) a remote
|
||||
* session INSIDE a tmux server on the remote host, so the remote agent survives
|
||||
@@ -2039,6 +2165,27 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The statusLine exporter for one claude launch, and why it is missing when it is.
|
||||
* Registry-gated (capabilities.statusLineTelemetry, claude only today), local spawns
|
||||
* only (remote/docker have their own separate command builders). Resolving still runs
|
||||
* with collection off, because it also self-heals: it strips any legacy disk-written
|
||||
* exporter from an older Codeman build the first time a session starts in that
|
||||
* workspace again. `state` lands on the mux record so the Status row can
|
||||
* say why no cache report will come, instead of "not reported yet" forever.
|
||||
*/
|
||||
private async _resolveStatusLine(
|
||||
mode: SessionMode,
|
||||
workingDir: string,
|
||||
remoteOrDocker: boolean
|
||||
): Promise<{ command?: string; state?: StatusLineExporter }> {
|
||||
if (!getCli(mode)?.capabilities.statusLineTelemetry) return {};
|
||||
if (remoteOrDocker) return { state: 'remote' };
|
||||
const enabled = await readPlanUsageTelemetryEnabled();
|
||||
const command = await resolveStatusLineCliCommand(workingDir, enabled);
|
||||
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the user's own REAL statusLine command (found by
|
||||
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
|
||||
@@ -2156,14 +2303,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
// Registry-gated (capabilities.statusLineTelemetry — claude only today), local
|
||||
// spawns only (remote/docker have their own separate command builders — out of
|
||||
// scope here). Also self-heals: strips any legacy disk-written exporter from an
|
||||
// older Codeman build the first time a session starts in that workspace again.
|
||||
const statusLineCommand =
|
||||
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
|
||||
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
|
||||
: undefined;
|
||||
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
|
||||
mode,
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
||||
// settings precedence) — exported below so the shared exporter script
|
||||
// can wrap it. Only worth discovering when we're actually injecting.
|
||||
@@ -2327,6 +2471,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
statusLine,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -2451,11 +2596,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
// See createSession()'s identical resolution for rationale.
|
||||
const statusLineCommand =
|
||||
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
|
||||
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
|
||||
: undefined;
|
||||
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
|
||||
mode,
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
@@ -2518,6 +2663,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.clearPaneExit(muxName);
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
if (session.statusLine !== statusLine) {
|
||||
session.statusLine = statusLine;
|
||||
this.saveSessions();
|
||||
}
|
||||
return pid;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to respawn pane:', err);
|
||||
@@ -3870,7 +4019,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
|
||||
? Math.trunc(requestedLines)
|
||||
: DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
const captureFlags = fullHistory ? `capture-pane -p -e -J -S -${historyLines}` : 'capture-pane -p -e';
|
||||
// With a scrollback override this pane's own history is not read at all:
|
||||
// `-S 0` starts at the top of the visible frame.
|
||||
const scrollbackOverride = fullHistory ? opts?.scrollbackOverride : undefined;
|
||||
const captureFlags = !fullHistory
|
||||
? 'capture-pane -p -e'
|
||||
: scrollbackOverride !== undefined
|
||||
? 'capture-pane -p -e -J -S 0'
|
||||
: `capture-pane -p -e -J -S -${historyLines}`;
|
||||
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
|
||||
// (ENOBUFS) and would silently degrade full-history capture to the byte
|
||||
// buffer for exactly the long sessions it exists for — size it from the
|
||||
@@ -3883,14 +4039,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
execOpts.maxBuffer =
|
||||
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
||||
}
|
||||
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
|
||||
const rawCapture = joinScrollbackAndFrame(
|
||||
scrollbackOverride,
|
||||
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts),
|
||||
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
|
||||
);
|
||||
// Query the cursor BEFORE deciding anything else. On the full-history path
|
||||
// it settles both how the capture is trimmed and whether a cursor move is
|
||||
// appended, and those two have to agree: trailing blank rows are only safe
|
||||
// to keep when a move follows to put the caret back above them.
|
||||
const geometry = queryPaneCursor(() =>
|
||||
execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
);
|
||||
@@ -3902,6 +4062,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// geometry is reported there for diagnosis rather than for repair. Only
|
||||
// the caller can see both sizes, so hand it this one.
|
||||
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
|
||||
// Same query, no extra tmux call: how much scrollback a full-history pull
|
||||
// could return. A pane in the alternate screen (fullscreen claude) holds
|
||||
// none, and the partial-history notice must not promise it. A caller that
|
||||
// bounded the capture with `historyLimitLines` also reads it to tell
|
||||
// whether the bound cut anything.
|
||||
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
|
||||
// A pane in the alternate screen keeps no history (a nested tmux client
|
||||
// holds it there), so its capture is one screen however deep it reads.
|
||||
if (opts && geometry?.alternateOn !== undefined) opts.capturedAlternateOn = geometry.alternateOn;
|
||||
|
||||
if (fullHistory) {
|
||||
// Without geometry there is no cursor move, so fall back to the old trim.
|
||||
|
||||
@@ -724,6 +724,51 @@ export interface PaneExit {
|
||||
at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized prompt-cache state of a session's main conversation, from the statusline's
|
||||
* `prompt_cache` object (`parsePromptCache` in usage-telemetry.ts). `warm` is the only
|
||||
* field Claude always reports: once the prefix has gone cold it sends `warm:false` with a
|
||||
* null `expires_at`.
|
||||
*/
|
||||
export interface PromptCacheStatus {
|
||||
warm: boolean;
|
||||
/** Cache lifetime of the current prefix; Claude Code only ever reports these two. */
|
||||
ttl?: '5m' | '1h';
|
||||
/** Epoch MILLISECONDS when the cached prefix goes cold (the statusline reports seconds). */
|
||||
expiresAt?: number;
|
||||
/** Tokens the next request re-writes if the cache has gone cold by then. */
|
||||
recacheTokensIfCold?: number;
|
||||
/** Requests this session that re-processed content the cache already held. */
|
||||
misses?: number;
|
||||
/** Cache rebuilds that followed a compaction or a clearing of old tool results. */
|
||||
expectedRebuilds?: number;
|
||||
/** Cache read tokens as a fraction of all input tokens this session, 0-1. */
|
||||
hitRatio?: number;
|
||||
/** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */
|
||||
lastMissCauses?: string[];
|
||||
/**
|
||||
* Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle
|
||||
* compaction, or a clearing of old tool results): the first warm report whose
|
||||
* `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds,
|
||||
* dropped at the next request. Derived in `Session.setPromptCache`, not reported by the
|
||||
* statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted
|
||||
* at HH:MM"; it is never an alert.
|
||||
*/
|
||||
compactedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch carries Codeman's statusLine exporter, the only source of
|
||||
* `promptCache`, and why not when it does not. Decided once per claude spawn
|
||||
* (`TmuxManager`), so a setting flipped later does not change it until the next launch.
|
||||
* - `injected`: the exporter is on the launch.
|
||||
* - `collection-off`: plan-usage collection (`showPlanUsageLimits`) was off at launch.
|
||||
* - `workspace-statusline`: the workspace's own `.claude/settings.local.json` sets a
|
||||
* statusLine (or could not be read), and Codeman never replaces one there.
|
||||
* - `remote`: remote and Docker launches never carry the exporter.
|
||||
*/
|
||||
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
|
||||
|
||||
export interface SessionState {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
@@ -775,6 +820,16 @@ export interface SessionState {
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/**
|
||||
* Prompt-cache state of the main conversation as Claude Code last reported it on the
|
||||
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Absent until the first
|
||||
* report. Restored on a restart (the Session constructor's `promptCache`) so the readout
|
||||
* is not blank until the pane's next render; the readout is time-aware, so a value whose
|
||||
* `expiresAt` has passed reads as cold rather than as a stale warm.
|
||||
*/
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
|
||||
statusLineExporter?: StatusLineExporter;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
|
||||
* before the first API response and for non-subscriber auth — both yield null.
|
||||
*
|
||||
* Since Claude Code v2.1.251 the same blob carries a `prompt_cache` object for the
|
||||
* main conversation (`warm`, `ttl`, `expires_at`, `recache_tokens_if_cold`,
|
||||
* `misses`, `last_miss_cause`, ...). `parsePromptCache` normalizes the subset
|
||||
* Codeman reads, and the footer shows it as `cache:until HH:MM` while warm and
|
||||
* `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes,
|
||||
* so the footer flips to cold on its own.
|
||||
*
|
||||
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
|
||||
* response and selects only the main `codex` bucket, excluding model-specific
|
||||
* buckets. All functions are pure for testability. See
|
||||
@@ -23,6 +30,10 @@
|
||||
* @module usage-telemetry
|
||||
*/
|
||||
|
||||
import type { PromptCacheStatus } from './types/index.js';
|
||||
|
||||
export type { PromptCacheStatus };
|
||||
|
||||
/** A single normalized plan-usage window. */
|
||||
export interface UsageWindow {
|
||||
/** Percent of the window consumed, 0–100. */
|
||||
@@ -52,6 +63,24 @@ export interface RawStatuslinePayload {
|
||||
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
|
||||
cost?: { total_cost_usd?: number };
|
||||
model?: { display_name?: string };
|
||||
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
|
||||
prompt_cache?: RawPromptCache | null;
|
||||
}
|
||||
|
||||
/** Raw `prompt_cache` object as Claude emits it (the subset Codeman reads; every field null-tolerant). */
|
||||
export interface RawPromptCache {
|
||||
warm?: boolean | null;
|
||||
ttl?: string | null;
|
||||
/** Epoch SECONDS when the cached prefix goes cold; null once the last response reported no cache tokens. */
|
||||
expires_at?: number | null;
|
||||
/** Null right after a compaction or tool-result clearing, until the next request records the rewritten size. */
|
||||
recache_tokens_if_cold?: number | null;
|
||||
misses?: number | null;
|
||||
/** Cache rebuilds that followed a compaction or a clearing of old tool results (counted at the request after one; not read for the compaction stamp). */
|
||||
expected_rebuilds?: number | null;
|
||||
/** Cache read tokens as a fraction of all input tokens this session, 0-1; null while those counts are all zero. */
|
||||
hit_ratio?: number | null;
|
||||
last_miss_cause?: { causes?: unknown } | null;
|
||||
}
|
||||
|
||||
interface RawCodexRateLimitWindow {
|
||||
@@ -107,6 +136,43 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the statusline's `prompt_cache` object. Null when the payload has none
|
||||
* (pre-first-response, or a Claude Code older than v2.1.251) or when `warm` is not
|
||||
* a boolean, so the caller can treat the cache state as unknown rather than cold.
|
||||
*/
|
||||
export function parsePromptCache(data: RawStatuslinePayload | undefined): PromptCacheStatus | null {
|
||||
const pc = data?.prompt_cache;
|
||||
if (!pc || typeof pc.warm !== 'boolean') return null;
|
||||
const s: PromptCacheStatus = { warm: pc.warm };
|
||||
if (pc.ttl === '5m' || pc.ttl === '1h') s.ttl = pc.ttl;
|
||||
if (typeof pc.expires_at === 'number' && Number.isFinite(pc.expires_at) && pc.expires_at > 0) {
|
||||
// Validate the CONVERTED ms, not just the seconds: a huge seconds value overflows to
|
||||
// Infinity or lands outside the Date range after *1000, which renders "cache:until
|
||||
// NaN:NaN". 8.64e15 is the max absolute epoch ms a Date can represent.
|
||||
const ms = Math.round(pc.expires_at * 1000);
|
||||
if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) s.expiresAt = ms;
|
||||
}
|
||||
if (typeof pc.recache_tokens_if_cold === 'number' && Number.isFinite(pc.recache_tokens_if_cold)) {
|
||||
s.recacheTokensIfCold = Math.max(0, Math.round(pc.recache_tokens_if_cold));
|
||||
}
|
||||
if (typeof pc.misses === 'number' && Number.isFinite(pc.misses)) {
|
||||
s.misses = Math.max(0, Math.round(pc.misses));
|
||||
}
|
||||
if (typeof pc.expected_rebuilds === 'number' && Number.isFinite(pc.expected_rebuilds)) {
|
||||
s.expectedRebuilds = Math.max(0, Math.round(pc.expected_rebuilds));
|
||||
}
|
||||
if (typeof pc.hit_ratio === 'number' && Number.isFinite(pc.hit_ratio)) {
|
||||
s.hitRatio = Math.min(1, Math.max(0, pc.hit_ratio));
|
||||
}
|
||||
const causes = pc.last_miss_cause?.causes;
|
||||
if (Array.isArray(causes)) {
|
||||
const names = causes.filter((c): c is string => typeof c === 'string' && c.length > 0).map((c) => c.slice(0, 60));
|
||||
if (names.length) s.lastMissCauses = names;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
|
||||
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
@@ -140,6 +206,8 @@ export interface SessionStatus {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
contextUsedPercentage?: number;
|
||||
/** Prompt-cache state of the main conversation, when the statusline reports it. */
|
||||
cache?: PromptCacheStatus;
|
||||
}
|
||||
|
||||
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
|
||||
@@ -166,19 +234,34 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
|
||||
if (typeof cw?.used_percentage === 'number') {
|
||||
s.contextUsedPercentage = clampPct(cw.used_percentage);
|
||||
}
|
||||
const cache = parsePromptCache(data);
|
||||
if (cache) s.cache = cache;
|
||||
return Object.keys(s).length ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||
* emit the rate_limits JSON at all, so this is what that command prints back
|
||||
* when it has no statusline of the user's own to wrap. With nothing to show it
|
||||
* returns '' rather than a brand word: a bare `codeman` on the statusline is
|
||||
* the symptom discussion #405 opened with.
|
||||
* `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold`
|
||||
* once Claude reports it cold or the render happened past `expires_at`. A clock
|
||||
* time rather than a countdown because the footer only re-renders on statusline
|
||||
* triggers, so a countdown would sit stale on screen between turns.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
function formatCacheGroup(c: PromptCacheStatus, now: number): string {
|
||||
if (!c.warm || c.expiresAt == null || c.expiresAt <= now) return 'cache:cold';
|
||||
const d = new Date(c.expiresAt);
|
||||
return `cache:until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until 19:00` — NOT
|
||||
* the plan limits, which live in the Codeman header chip. Claude requires a
|
||||
* statusLine command to emit the rate_limits JSON at all, so this is what that
|
||||
* command prints back when it has no statusline of the user's own to wrap. With
|
||||
* nothing to show it returns '' rather than a brand word: a bare `codeman` on the
|
||||
* statusline is the symptom discussion #405 opened with. `now` is injectable so
|
||||
* the cache group is testable.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null, now: number = Date.now()): string {
|
||||
if (!s) return '';
|
||||
const groups: string[] = [];
|
||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||
@@ -187,6 +270,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||
if (tok.length) groups.push(tok.join(' '));
|
||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||
if (s.cache) groups.push(formatCacheGroup(s.cache, now));
|
||||
return groups.length ? groups.join(' ') : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# CLAUDE.md (src/web: frontend invariants)
|
||||
|
||||
> Loaded on demand: Claude Code reads this when a file under `src/web/` is opened with the Read tool, and the root `CLAUDE.md` tells sessions to Read it before editing `src/web/public/`. These rules were moved here from the root file verbatim. **All paths below are relative to the repo root**, like the root file (only the markdown link targets are relative to this file, so they resolve on GitHub). The root `CLAUDE.md` still holds every cross-cutting rule (session safety, testing, security, server-side patterns).
|
||||
>
|
||||
> **This file is in `.prettierignore`** for the same reason as the root one: Prettier escapes underscores in glob-heavy paths.
|
||||
|
||||
## Frontend key patterns
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](../../docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](../../docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](../../docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. SuchLine truncated
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](../../docs/architecture-invariants.md#auto-copy-copy-on-select)
|
||||
|
||||
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
|
||||
|
||||
**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)
|
||||
|
||||
**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`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. 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)
|
||||
|
||||
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
|
||||
|
||||
## Frontend modules and UI subsystems
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles)
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](../../docs/architecture-invariants.md#phone-overview-home-screen)
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
**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)
|
||||
|
||||
**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.
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
|
||||
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](../../docs/architecture-invariants.md#theme-skins)
|
||||
|
||||
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](../../docs/architecture-invariants.md#foldable-settings-identity)
|
||||
|
||||
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices)
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](../../docs/architecture-invariants.md#websocket-output-gap-reconcile)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](../../docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
|
||||
|
||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||
@@ -85,6 +85,13 @@ export interface ApprovalItem {
|
||||
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
|
||||
*/
|
||||
options?: ApprovalOption[];
|
||||
/**
|
||||
* When true the dialog belongs to a third-party TUI whose keystrokes Codeman
|
||||
* cannot verify (today: DeepSeek Harness / `hooks: 'supervised'`). The item
|
||||
* is an ALERT only: the answer route refuses keystrokes, push omits Approve/
|
||||
* Deny actions, and the UI offers "Open session" instead of answer buttons.
|
||||
*/
|
||||
answerInTerminal?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalResolvedInfo {
|
||||
@@ -108,6 +115,11 @@ interface NotePromptArgs {
|
||||
* opens ALREADY acknowledged: see `notePrompt()`.
|
||||
*/
|
||||
watching?: string | null;
|
||||
/**
|
||||
* Alert-only item: answer in the terminal, not via Approve/Deny. See
|
||||
* `ApprovalItem.answerInTerminal`.
|
||||
*/
|
||||
answerInTerminal?: boolean;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
}
|
||||
@@ -261,6 +273,7 @@ export class ApprovalInbox {
|
||||
toolSummary: args.toolSummary,
|
||||
message: args.message,
|
||||
cwd: args.cwd,
|
||||
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
|
||||
};
|
||||
if (args.kind === 'idle' && args.watching) {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import {
|
||||
capabilityFromProxyPath,
|
||||
capabilityFromReferer,
|
||||
@@ -183,6 +184,20 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request is a GET for a rendered HTML file (`/html-view/<cap>/...`) with
|
||||
* a live capability. Same reasoning as the web-tab proxy: the page is sandboxed into
|
||||
* an opaque origin, so its asset requests carry no session cookie. Read-only by
|
||||
* construction (safe methods only), and the Host allowlist still applies. OPTIONS
|
||||
* is the page's CORS preflight, which registerSecurityHeaders answers itself and
|
||||
* never hands to a route.
|
||||
*/
|
||||
function hasValidHtmlViewCapability(req: FastifyRequest): boolean {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') return false;
|
||||
const cap = capabilityFromHtmlViewPath((req.url ?? '').split('?')[0]);
|
||||
return !!cap && htmlViewCapabilities.resolve(cap) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A web-tab frame that navigated itself off the proxy prefix (see
|
||||
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
|
||||
@@ -368,6 +383,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// Rendered HTML file, likewise authenticated by its capability.
|
||||
if (hasValidHtmlViewCapability(req)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||
if (serveLostWebviewFrame(req, reply)) return;
|
||||
|
||||
@@ -507,6 +527,8 @@ function registerMultiUserAuthHook(
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req, basePath)) return;
|
||||
// Rendered HTML file, likewise authenticated by its capability.
|
||||
if (hasValidHtmlViewCapability(req)) return;
|
||||
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||
if (serveLostWebviewFrame(req, reply)) return;
|
||||
|
||||
@@ -711,6 +733,16 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
|
||||
// A rendered HTML page (`/html-view/<cap>/...`) is opaque-origin for the same
|
||||
// reason, so a fetch() of its own `data.json` with a non-safelisted header
|
||||
// preflights with `Origin: null`. Answer it here, read-only: the route serves
|
||||
// GET and HEAD only, and no credentials are involved (`*` is valid for both).
|
||||
if (hasValidHtmlViewCapability(req)) {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Access-Control-Allow-Methods', 'GET, HEAD');
|
||||
reply.header('Access-Control-Allow-Headers', '*');
|
||||
reply.header('Access-Control-Max-Age', '600');
|
||||
}
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
|
||||
import type { Session } from '../../session.js';
|
||||
import type { SessionState } from '../../types.js';
|
||||
import type { TabLayout } from '../../tab-layout.js';
|
||||
import type { SessionPlacementHint } from '../../tab-layout-service.js';
|
||||
|
||||
export interface SessionPort {
|
||||
readonly sessions: ReadonlyMap<string, Session>;
|
||||
addSession(session: Session): Promise<void>;
|
||||
/**
|
||||
* Register a created session with the owner's tab layout. `placement.tabGroupId`
|
||||
* puts it at the end of that group in the same layout write (ignored when the
|
||||
* group is not in the owner's layout). Resolves to the committed layout.
|
||||
*/
|
||||
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
|
||||
@@ -193,6 +193,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
[SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'],
|
||||
|
||||
// Scheduled runs
|
||||
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
|
||||
@@ -703,6 +704,9 @@ const URL_SESSION_WAIT_MS = 30000;
|
||||
*/
|
||||
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
|
||||
|
||||
/** How long a collapsed group's header stays marked after a tab arrives in it. */
|
||||
const TAB_GROUP_ARRIVAL_MS = 2000;
|
||||
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
@@ -768,6 +772,12 @@ class CodemanApp {
|
||||
// their scrollback can be very large; full history stays available on demand.
|
||||
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
|
||||
this._fullHistoryLoaded = new Set();
|
||||
// Rows of scrollback tmux reported above the visible frame on this session's
|
||||
// last capture (`paneHistoryLines`). 0 marks a pane with no history to load
|
||||
// (a fullscreen CLI in the alternate screen), which a tab switch serves from
|
||||
// the small `full=1` capture instead of the 1 MiB byte tail: see
|
||||
// _notePaneHistory and selectSession.
|
||||
this._paneHistoryLines = new Map(); // Map<sessionId, number>
|
||||
// Cooldown per session for the scroll-to-top "load more history" re-pull.
|
||||
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
|
||||
this._fullHistoryRepullInFlight = false;
|
||||
@@ -1437,6 +1447,7 @@ class CodemanApp {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
this.closeWorktreeModal?.();
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
this.closeShortcutOverlay?.();
|
||||
@@ -1667,8 +1678,12 @@ class CodemanApp {
|
||||
* side-effect-light. Calling it again for an already-open window just raises
|
||||
* that window.
|
||||
* @param {string} id session id
|
||||
* @param {{width?: number, height?: number, left?: number, top?: number}} [placement]
|
||||
* the new window's size and screen position (a detached tile opens its own
|
||||
* size, where it was let go); omitted, the default 960x680 wherever the
|
||||
* browser puts it. A host window ignores it.
|
||||
*/
|
||||
detachSession(id) {
|
||||
detachSession(id, placement = null) {
|
||||
if (this.isSoloWindow) return; // a solo window can't spawn more
|
||||
if (!this.sessions.has(id)) return;
|
||||
// Already detached → raise the existing popup instead of opening (or
|
||||
@@ -1696,7 +1711,7 @@ class CodemanApp {
|
||||
this._postWindowMessage({ type: 'detached', id });
|
||||
return;
|
||||
}
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
const features = this._detachWindowFeatures(placement);
|
||||
let win = null;
|
||||
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
||||
if (!win) {
|
||||
@@ -1710,6 +1725,21 @@ class CodemanApp {
|
||||
try { win.focus(); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The window.open features of a pop-out: `placement`'s size (default
|
||||
* 960x680) and, when it has one, its screen position. Only finite numbers
|
||||
* make it into the string.
|
||||
*/
|
||||
_detachWindowFeatures(placement) {
|
||||
const int = (v) => (Number.isFinite(v) ? Math.round(v) : null);
|
||||
const width = int(placement?.width) ?? 960;
|
||||
const height = int(placement?.height) ?? 680;
|
||||
const left = int(placement?.left);
|
||||
const top = int(placement?.top);
|
||||
const at = left !== null && top !== null ? `,left=${left},top=${top}` : '';
|
||||
return `width=${width},height=${height}${at},menubar=no,toolbar=no,location=no,status=no`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedding app's window opener, when there is one. A native wrapper
|
||||
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
|
||||
@@ -1878,11 +1908,15 @@ class CodemanApp {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (this.isSoloWindow) {
|
||||
// Roll-call has no id (broadcast to all) — answer before the id filter.
|
||||
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
|
||||
// A window that has handed its session back stays silent.
|
||||
if (msg.type === 'roll-call') {
|
||||
if (!this._soloReleased) this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
|
||||
return;
|
||||
}
|
||||
if (msg.id !== this.soloSessionId) return;
|
||||
// A host window ignores window.close()/focus() from script it did not
|
||||
// open by window.open, so ask the host when it offers the call.
|
||||
if (msg.type === 'close-request') { this._closeSoloWindow(); }
|
||||
if (msg.type === 'close-request') { this._releaseSoloWindow(); }
|
||||
else if (msg.type === 'focus-request') {
|
||||
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
|
||||
}
|
||||
@@ -1890,6 +1924,10 @@ class CodemanApp {
|
||||
}
|
||||
// Dashboard side.
|
||||
if (msg.type === 'detached' && msg.id) {
|
||||
// A pop-out this window just docked as a tile (Detach Tiles), answering
|
||||
// a roll-call on its way out: not a new pop-out, which would take the
|
||||
// tile straight back off.
|
||||
if (this._tileDockedRecently?.(msg.id)) return;
|
||||
this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock
|
||||
this._detachPingPending?.delete(msg.id); // and proves liveness for this tick
|
||||
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
|
||||
@@ -1899,6 +1937,9 @@ class CodemanApp {
|
||||
} else if (msg.type === 'detach-request' && msg.id) {
|
||||
// Future gesture hook: another window asks the dashboard to detach a tab.
|
||||
this.detachSession(msg.id);
|
||||
} else if (msg.type === 'tile-adopted' && msg.id && msg.by !== this._wsTabNonce) {
|
||||
// Another window docked a tile dragged out of this one (tile-grid.js).
|
||||
this._onTileAdoptedElsewhere?.(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1945,6 +1986,41 @@ class CodemanApp {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo window: a dashboard took the session back (its re-dock, or a
|
||||
* dashboard docked it as a tile). Says so at once and stops answering
|
||||
* roll-calls, then closes. A window the browser will not let a script
|
||||
* close (one opened by typing its URL) says where the session went
|
||||
* instead, rather than staying a live pop-out that every dashboard would
|
||||
* mark detached again at its next roll-call.
|
||||
*/
|
||||
_releaseSoloWindow() {
|
||||
if (this._soloReleased) return;
|
||||
this._soloReleased = true;
|
||||
this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
|
||||
this._closeSoloWindow();
|
||||
setTimeout(() => {
|
||||
if (!window.closed) this._showSoloReleased();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/** Solo window: the session went back to a dashboard and this window could not close. */
|
||||
_showSoloReleased() {
|
||||
if (document.querySelector('.solo-gone-overlay')) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'solo-gone-overlay';
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = 'Session moved';
|
||||
const text = document.createElement('p');
|
||||
text.textContent = 'This session is back in a Codeman window. This one can be closed.';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-primary';
|
||||
btn.textContent = 'Close window';
|
||||
btn.addEventListener('click', () => this._closeSoloWindow());
|
||||
el.append(title, text, btn);
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
/** Solo window: select the target session and apply minimal single-session
|
||||
* chrome. Called from handleInit once the session list has loaded. */
|
||||
_applySoloMode() {
|
||||
@@ -1960,6 +2036,8 @@ class CodemanApp {
|
||||
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
|
||||
const redock = document.getElementById('soloRedockBtn');
|
||||
if (redock) redock.style.display = '';
|
||||
// Detach Tiles: the title drags onto a dashboard's tiles to dock there.
|
||||
this._installSoloTileHandle?.();
|
||||
document.title = name + ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
|
||||
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
|
||||
// Neutralize the dashboard-only brand click in a solo window.
|
||||
@@ -2300,10 +2378,13 @@ class CodemanApp {
|
||||
// A session this tab is closing stays closed until the server answers.
|
||||
if (this._closingSessions?.has(data.id)) return;
|
||||
this.sessions.set(data.id, data);
|
||||
// Add new session to end of tab order
|
||||
if (!this.sessionOrder.includes(data.id)) {
|
||||
this.sessionOrder.push(data.id);
|
||||
this.saveSessionOrder();
|
||||
// The server placed it when it recorded the creation (a child after its
|
||||
// parent, a grouped creation in its group) and broadcast that order
|
||||
// (session:orderChanged); adopt it, appending only while that broadcast is
|
||||
// still on its way. Never echo it back through PUT /api/session-order:
|
||||
// the server reads that as a hand arrangement of every tab.
|
||||
this.syncSessionOrder();
|
||||
}
|
||||
// Idempotent per id: the POST response and the session:created event both
|
||||
// land here, and a batch launched together cascades in creation order.
|
||||
@@ -2744,7 +2825,13 @@ class CodemanApp {
|
||||
const filePath = pathLink.dataset.path;
|
||||
// A rendered document's links name the session the preview was opened
|
||||
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
|
||||
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId);
|
||||
// A path the linkifier found in prose may be relative: resolve it against
|
||||
// that session's working directory. A markdown document's own links
|
||||
// (rebased, no `linkified` mark) already are what the routes expect.
|
||||
const sessionId = pathLink.dataset.sessionId || this.activeSessionId;
|
||||
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
|
||||
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
|
||||
else if (filePath) this.openFilePreview(target || filePath, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2862,7 +2949,8 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make absolute file paths in a rendered message clickable.
|
||||
* Make file paths in a rendered message clickable: absolute, `~/` and
|
||||
* relative ones (`findFilePathLinks`, constants.js).
|
||||
*
|
||||
* The terminal's link provider never sees these: the response viewer is
|
||||
* markdown, and a path the agent wrote as prose or inline code renders as
|
||||
@@ -2880,8 +2968,7 @@ class CodemanApp {
|
||||
if (!root || typeof document === 'undefined') return;
|
||||
// Guarded: a stale cached constants.js must degrade to plain text, not throw
|
||||
// out of the middle of rendering a message.
|
||||
if (typeof absoluteFilePathPattern !== 'function') return;
|
||||
const pattern = absoluteFilePathPattern();
|
||||
if (typeof findFilePathLinks !== 'function') return;
|
||||
|
||||
// Collect first: replacing a node while the walker is positioned on it
|
||||
// invalidates the traversal.
|
||||
@@ -2889,27 +2976,26 @@ class CodemanApp {
|
||||
const targets = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (node.parentElement?.closest('a')) continue;
|
||||
pattern.lastIndex = 0;
|
||||
if (pattern.test(node.nodeValue || '')) targets.push(node);
|
||||
const found = findFilePathLinks(node.nodeValue || '');
|
||||
if (found.length > 0) targets.push([node, found]);
|
||||
}
|
||||
|
||||
for (const node of targets) {
|
||||
for (const [node, found] of targets) {
|
||||
const value = node.nodeValue;
|
||||
const frag = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
let match;
|
||||
pattern.lastIndex = 0;
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
const path = match[1];
|
||||
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
|
||||
for (const { path, index, folder } of found) {
|
||||
if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index)));
|
||||
const link = document.createElement('a');
|
||||
link.className = 'rv-path';
|
||||
link.href = '#';
|
||||
link.dataset.path = path;
|
||||
link.dataset.linkified = '1';
|
||||
if (folder) link.dataset.folder = '1';
|
||||
link.title = path;
|
||||
link.textContent = path;
|
||||
frag.appendChild(link);
|
||||
cursor = match.index + path.length;
|
||||
cursor = index + path.length;
|
||||
}
|
||||
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
|
||||
node.parentNode?.replaceChild(frag, node);
|
||||
@@ -3203,15 +3289,16 @@ class CodemanApp {
|
||||
this._terminalRefreshOwner = refreshOwner;
|
||||
try {
|
||||
// A shell can retain a multi-megabyte/100k-line tmux history. Automatic
|
||||
// recovery stays bounded just like normal shell selection; only the
|
||||
// explicit "Load full history" action is allowed to pay for a full replay.
|
||||
// recovery stays bounded just like normal shell selection (tmux's rendered
|
||||
// history, SHELL_LOAD_QUERY); only the explicit "Load full history" action
|
||||
// is allowed to pay for a full replay.
|
||||
// TUI modes still recover the whole picture, with the downgrade guard for
|
||||
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
|
||||
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
|
||||
let capture = await this._fetchTerminalCapture(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
|
||||
: `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
let headersReceivedAt = capture.headersAt;
|
||||
@@ -3221,6 +3308,7 @@ class CodemanApp {
|
||||
headersReceivedAt = capture.headersAt;
|
||||
data = capture.json?.data ?? {};
|
||||
}
|
||||
this._notePaneHistory?.(sessionId, data);
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
// history into the terminal the user is now looking at. The window is two
|
||||
// fetches wide in the fallback case, so this guard is not optional.
|
||||
@@ -3308,8 +3396,13 @@ class CodemanApp {
|
||||
try {
|
||||
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
|
||||
// so it needs the full-history budget. Defaulting to the tail budget
|
||||
// gave the largest payload the smallest deadline.
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
|
||||
// gave the largest payload the smallest deadline. A shell reloads tmux's
|
||||
// rendered history instead (SHELL_LOAD_QUERY), never the byte recording.
|
||||
const shellSession = this.sessions.get(data.id)?.mode === 'shell';
|
||||
const capture = await this._fetchTerminalCapture(
|
||||
shellSession ? `/api/sessions/${data.id}/terminal?${SHELL_LOAD_QUERY}` : `/api/sessions/${data.id}/terminal`,
|
||||
{ full: !shellSession }
|
||||
);
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const termData = capture.json?.data ?? {};
|
||||
|
||||
@@ -6158,6 +6251,8 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
this._syncTabTreeSelection(container);
|
||||
// The Focus shortcut of the selected tab is marked aria-current.
|
||||
this._renderTabFocus();
|
||||
// #257: selection used to stop at the class toggle. On phones/tablets the
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
@@ -6649,6 +6744,10 @@ class CodemanApp {
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
// Same deal for the desktop home screen's tab column.
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
// Focus shortcuts carry the status dot and alert of the tab they stand for.
|
||||
// A no-op unless what the section would draw changed (and after a full
|
||||
// rebuild above it already has).
|
||||
this._renderTabFocus();
|
||||
// The full-render path already redraws the connection SVG; this incremental
|
||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||
// it, sliding the lineage lines off their anchors. Only pay for it when there
|
||||
@@ -6975,6 +7074,7 @@ class CodemanApp {
|
||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._noteTabGroupArrivals(container, groupProjection);
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
|
||||
@@ -7001,6 +7101,10 @@ class CodemanApp {
|
||||
// Set up keyboard navigation for tabs
|
||||
this.setupTabKeyboardNavigation(container);
|
||||
|
||||
// The Focus section sits above this list in the rail, so it is drawn before
|
||||
// the connection lines measure the rows.
|
||||
this._renderTabFocus();
|
||||
|
||||
// Update connection lines after tabs change (positions may have shifted)
|
||||
this.updateConnectionLines();
|
||||
|
||||
@@ -7249,6 +7353,38 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab that first appears inside a COLLAPSED group (a spawned child, a session
|
||||
* another device placed there) would otherwise arrive silently, the header
|
||||
* count being its only trace. Its group header is marked for a moment instead
|
||||
* (`.tab-layout-group-arrived`). Only a ref absent from the previous grouped
|
||||
* render counts, so collapsing a group, or the first paint, marks nothing.
|
||||
*/
|
||||
_noteTabGroupArrivals(container, projection) {
|
||||
const previous = this._renderedTabGroupRefs;
|
||||
this._renderedTabGroupRefs = projection ? new Set(Object.keys(projection.sectionByRef || {})) : null;
|
||||
if (!projection) return;
|
||||
const now = Date.now();
|
||||
const arrivals = (this._tabGroupArrivals ||= new Map());
|
||||
if (previous) {
|
||||
for (const [key, groupId] of Object.entries(projection.hiddenTabGroupByRef || {})) {
|
||||
if (previous.has(key)) continue;
|
||||
arrivals.set(groupId, now + TAB_GROUP_ARRIVAL_MS);
|
||||
setTimeout(() => {
|
||||
if ((arrivals.get(groupId) ?? 0) > Date.now()) return;
|
||||
arrivals.delete(groupId);
|
||||
for (const header of this.$('sessionTabs')?.querySelectorAll('[data-tab-group-header]') || []) {
|
||||
if (header.dataset.tabGroupHeader === groupId) header.classList.remove('tab-layout-group-arrived');
|
||||
}
|
||||
}, TAB_GROUP_ARRIVAL_MS);
|
||||
}
|
||||
}
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
|
||||
const until = arrivals.get(header.dataset.tabGroupHeader) ?? 0;
|
||||
header.classList.toggle('tab-layout-group-arrived', until > now);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep aria-selected on the grouped tree in step with the .active class. */
|
||||
_syncTabTreeSelection(container) {
|
||||
if (container?.getAttribute('role') !== 'tree') return;
|
||||
@@ -7535,6 +7671,9 @@ class CodemanApp {
|
||||
// rail (always so on the flat rail, which is every owner without groups).
|
||||
// Rebuild only when what the rail would draw actually changed.
|
||||
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
||||
// A Focus flag set on another device is not a structural change of the
|
||||
// list, so it would not rebuild it: draw the section on its own.
|
||||
else if (this._renderTabFocus()) this.updateConnectionLines?.();
|
||||
// Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
|
||||
if (next && !this._tabLayoutRestoreChecked) {
|
||||
this._tabLayoutRestoreChecked = true;
|
||||
@@ -7571,6 +7710,8 @@ class CodemanApp {
|
||||
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
|
||||
activeSessionId: this.activeSessionId,
|
||||
activeWebviewId: this.activeWebviewId,
|
||||
// A session spawned by another one shows up in its parent's group at once.
|
||||
parents: this._tabLayoutParents(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7608,6 +7749,226 @@ class CodemanApp {
|
||||
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: the Focus section (pinned shortcuts)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// A ref flagged `focus: true` in the layout gets a SHORTCUT in a small
|
||||
// "Focus" section pinned to the top of the vertical rail; the row itself stays
|
||||
// in its group. The section is its own element in #tabRail, OUTSIDE
|
||||
// #sessionTabs, on purpose: it shows above the flat rail as well as the
|
||||
// grouped one, and nothing that walks #sessionTabs (Alt+N badges, the tree's
|
||||
// roving stop and posinset, lineage lines, drag, the row filters, the rail
|
||||
// sort) ever sees a shortcut. Its own keyboard model is a list with one tab
|
||||
// stop and Up/Down/Home/End between shortcuts; each shortcut is a button, so
|
||||
// Enter/Space select its tab, and the context-menu key opens "Remove from
|
||||
// Focus". Collapse is per-device localStorage, like a group's.
|
||||
|
||||
/** True when `ref` carries `focus: true` in the held layout. */
|
||||
_tabRefFocused(ref) {
|
||||
const location = this._tabRefLocation(ref);
|
||||
return location?.refs[location.index]?.focus === true;
|
||||
}
|
||||
|
||||
/** Pin or unpin a row. One named operation through the edit coordinator. */
|
||||
setTabRefFocus(ref, focused) {
|
||||
if (!this._tabRefLocation(ref)) return false;
|
||||
return this.editTabLayout({ type: 'setFocus', ref: { kind: ref.kind, id: ref.id }, focused: focused === true }, `${ref.kind}:${ref.id}`);
|
||||
}
|
||||
|
||||
/** The row menu's Focus entry ([] where the rail cannot edit the layout). */
|
||||
_tabRefFocusActions(ref) {
|
||||
if (!this._tabLayoutEditable() || !this._tabRefLocation(ref)) return [];
|
||||
const focused = this._tabRefFocused(ref);
|
||||
return [{ label: focused ? 'Remove from Focus' : 'Add to Focus', run: () => this.setTabRefFocus(ref, !focused) }];
|
||||
}
|
||||
|
||||
_tabFocusCollapsedNow() {
|
||||
if (this._tabFocusCollapsed === undefined) {
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const loaded = storage ? window.CodemanTabLayout.loadFocusCollapsed(storage) : { collapsed: false, ok: false };
|
||||
if (!loaded.ok) this._tabCollapseStorageFailed = true;
|
||||
this._tabFocusCollapsed = loaded.collapsed;
|
||||
}
|
||||
return this._tabFocusCollapsed;
|
||||
}
|
||||
|
||||
/** Collapse/expand the Focus section. Per-device; a store failure leaves it expanded. */
|
||||
toggleTabFocusCollapsed(forceCollapsed) {
|
||||
const shouldCollapse = forceCollapsed === undefined ? !this._tabFocusCollapsedNow() : forceCollapsed === true;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const saved = storage ? window.CodemanTabLayout.saveFocusCollapsed(storage, shouldCollapse) : { collapsed: false, ok: false };
|
||||
if (!saved.ok) this._tabCollapseStorageFailed = true;
|
||||
this._tabFocusCollapsed = saved.collapsed;
|
||||
if (this._renderTabFocus()) this.updateConnectionLines?.();
|
||||
document.getElementById('tabFocusToggle')?.focus();
|
||||
return this._tabFocusCollapsed === shouldCollapse;
|
||||
}
|
||||
|
||||
/** The focused refs this rail draws now ([] off the vertical rail or without a layout). */
|
||||
_tabFocusRefs() {
|
||||
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return [];
|
||||
return window.CodemanTabLayout.focusRefs(this.tabLayout, {
|
||||
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
|
||||
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
|
||||
});
|
||||
}
|
||||
|
||||
/** The Focus section element, created once inside #tabRail just above the list. */
|
||||
_tabFocusHost() {
|
||||
let host = document.getElementById('tabFocus');
|
||||
if (host) return host;
|
||||
const rail = document.getElementById('tabRail');
|
||||
if (!rail) return null;
|
||||
host = document.createElement('section');
|
||||
host.id = 'tabFocus';
|
||||
host.className = 'tab-focus';
|
||||
host.hidden = true;
|
||||
host.setAttribute('aria-labelledby', 'tabFocusTitle');
|
||||
// Delegated, so the listeners survive every repaint of the section.
|
||||
host.addEventListener('keydown', (event) => this._handleTabFocusKeydown(event));
|
||||
host.addEventListener('click', (event) => {
|
||||
if (event.target?.closest?.('#tabFocusToggle')) {
|
||||
this.toggleTabFocusCollapsed();
|
||||
return;
|
||||
}
|
||||
const item = event.target?.closest?.('.tab-focus-item');
|
||||
if (item) this.openTabFocusShortcut(event, item.dataset.focusKey);
|
||||
});
|
||||
host.addEventListener('contextmenu', (event) => {
|
||||
const item = event.target?.closest?.('.tab-focus-item');
|
||||
if (item) this.openTabFocusMenu(event, item.dataset.focusKey, item);
|
||||
});
|
||||
const list = this.$('sessionTabs');
|
||||
if (list && list.parentElement === rail) rail.insertBefore(host, list);
|
||||
else rail.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the Focus section from the held layout. Cheap enough for every render
|
||||
* pass: the markup is compared with what was drawn last, and the DOM is only
|
||||
* touched when it changed. Returns true when it did (the rail's list moved,
|
||||
* so connector lines anchored to its rows need a redraw).
|
||||
*/
|
||||
_renderTabFocus() {
|
||||
const refs = this._tabFocusRefs();
|
||||
const host = refs.length ? this._tabFocusHost() : document.getElementById('tabFocus');
|
||||
if (!host) return false;
|
||||
if (!refs.length) {
|
||||
if (host.hidden && !host.firstChild) return false;
|
||||
host.hidden = true;
|
||||
host.replaceChildren();
|
||||
this._lastTabFocusHtml = '';
|
||||
return true;
|
||||
}
|
||||
// Keep the rail's list just below the section: applyTabOrientation()
|
||||
// re-appends #sessionTabs to the rail when the orientation flips.
|
||||
const list = this.$('sessionTabs');
|
||||
const rail = host.parentElement;
|
||||
if (list && rail && list.parentElement === rail && host.nextElementSibling !== list) rail.insertBefore(host, list);
|
||||
const collapsed = this._tabFocusCollapsedNow();
|
||||
const activeKey = this.activeWebviewId
|
||||
? `webview:${this.activeWebviewId}`
|
||||
: this.activeSessionId
|
||||
? `session:${this.activeSessionId}`
|
||||
: '';
|
||||
const focusedEl = host.contains(document.activeElement) ? document.activeElement : null;
|
||||
const focusedKey = focusedEl?.dataset?.focusKey || (focusedEl?.id === 'tabFocusToggle' ? 'toggle' : null);
|
||||
const keys = refs.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
// One tab stop in the list: the shortcut that had it, else the selected
|
||||
// tab's, else the first.
|
||||
const stopKey = [focusedKey, this._tabFocusStopKey, activeKey].find((key) => key && keys.includes(key)) || keys[0];
|
||||
const items = refs
|
||||
.map((ref) => {
|
||||
const key = `${ref.kind}:${ref.id}`;
|
||||
let name;
|
||||
let statusHtml;
|
||||
let alertClass = '';
|
||||
if (ref.kind === 'session') {
|
||||
const session = this.sessions.get(ref.id);
|
||||
name = this.getSessionName(session);
|
||||
statusHtml = `<span class="tab-status ${escapeHtml(session.status || 'idle')}" aria-hidden="true"></span>`;
|
||||
const alert = this.tabAlerts?.get(ref.id);
|
||||
alertClass = alert === 'action' ? ' tab-alert-action' : alert === 'idle' ? ' tab-alert-idle' : '';
|
||||
} else {
|
||||
const webview = this.webviews.get(ref.id);
|
||||
name = webview?.name || ref.id;
|
||||
statusHtml = `<span class="tab-web-icon" aria-hidden="true">${webview?.icon ? escapeHtml(webview.icon) : this._webviewGlobeIcon?.() || ''}</span>`;
|
||||
}
|
||||
// The name is user text: the whole button is kept away from the
|
||||
// translator (a session named "3 sessions" must not come out in Chinese).
|
||||
return (
|
||||
`<li class="tab-focus-entry"><button type="button" class="tab-focus-item${alertClass}" data-focus-key="${escapeHtml(key)}" data-i18n-skip ` +
|
||||
`tabindex="${key === stopKey ? '0' : '-1'}" aria-current="${key === activeKey ? 'true' : 'false'}" aria-label="${escapeHtml(name)}" title="${escapeHtml(name)}">` +
|
||||
`${statusHtml}<span class="tab-focus-name">${escapeHtml(name)}</span></button></li>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
const html =
|
||||
`<button type="button" id="tabFocusToggle" class="tab-focus-toggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="tabFocusList">` +
|
||||
`<span class="tab-layout-group-chevron" aria-hidden="true"></span><span class="tab-focus-title" id="tabFocusTitle">Focus</span>` +
|
||||
`<span class="tab-focus-count" aria-hidden="true">${refs.length}</span></button>` +
|
||||
`<ul class="tab-focus-list" id="tabFocusList" aria-labelledby="tabFocusTitle"${collapsed ? ' hidden' : ''}>${items}</ul>`;
|
||||
if (!host.hidden && this._lastTabFocusHtml === html) return false;
|
||||
host.classList.toggle('tab-focus--collapsed', collapsed);
|
||||
host.innerHTML = html;
|
||||
host.hidden = false;
|
||||
this._lastTabFocusHtml = html;
|
||||
if (focusedKey === 'toggle') document.getElementById('tabFocusToggle')?.focus();
|
||||
else if (focusedKey) host.querySelector(`[data-focus-key="${CSS.escape(focusedKey)}"]`)?.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
_tabFocusRefFromKey(key) {
|
||||
const at = typeof key === 'string' ? key.indexOf(':') : -1;
|
||||
if (at < 0) return null;
|
||||
const kind = key.slice(0, at);
|
||||
return kind === 'session' || kind === 'webview' ? { kind, id: key.slice(at + 1) } : null;
|
||||
}
|
||||
|
||||
/** A shortcut was clicked: select its tab, exactly as the row would. */
|
||||
openTabFocusShortcut(event, key) {
|
||||
const ref = this._tabFocusRefFromKey(key);
|
||||
if (!ref) return undefined;
|
||||
this._tabFocusStopKey = key;
|
||||
if (ref.kind === 'webview') return this.handleWebviewTabClick?.(event, ref.id);
|
||||
return this.handleSessionTabClick(event, ref.id);
|
||||
}
|
||||
|
||||
/** Right-click, Shift+F10 or the menu key on a shortcut (`trigger` anchors the menu). */
|
||||
openTabFocusMenu(event, key, trigger) {
|
||||
const ref = this._tabFocusRefFromKey(key);
|
||||
if (!ref) return false;
|
||||
this._tabFocusStopKey = key;
|
||||
// A delegated listener's currentTarget is the section; anchor on the shortcut.
|
||||
const anchored = trigger
|
||||
? { preventDefault: () => event?.preventDefault?.(), stopPropagation: () => event?.stopPropagation?.(), currentTarget: trigger, type: event?.type, clientX: event?.clientX }
|
||||
: event;
|
||||
return this._openTabLayoutMenu(anchored, `focus:${key}`, 'Focus actions', [
|
||||
{ label: 'Remove from Focus', run: () => this.setTabRefFocus(ref, false) },
|
||||
]);
|
||||
}
|
||||
|
||||
/** Up/Down/Home/End move between shortcuts; the list keeps one tab stop. */
|
||||
_handleTabFocusKeydown(event) {
|
||||
const current = event.target?.closest?.('.tab-focus-item');
|
||||
if (!current || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const items = [...event.currentTarget.querySelectorAll('.tab-focus-item')];
|
||||
const index = items.indexOf(current);
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? items[0]
|
||||
: event.key === 'End'
|
||||
? items[items.length - 1]
|
||||
: items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
for (const item of items) item.tabIndex = item === next ? 0 : -1;
|
||||
this._tabFocusStopKey = next.dataset.focusKey;
|
||||
next.focus();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: editing groups from the vertical rail
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -7771,8 +8132,8 @@ class CodemanApp {
|
||||
if (!this._tabLayoutEditable()) return [];
|
||||
const location = this._tabRefLocation(ref);
|
||||
if (!location) return [];
|
||||
const actions = [];
|
||||
const grouped = this.tabLayout.groups.length > 0;
|
||||
const actions = grouped ? this._tabRefPlacementActions(ref) : [];
|
||||
// Up/down follow the STORED order, which is what the rail paints unless a
|
||||
// sort is on (then the sort decides and there is nothing to reorder).
|
||||
if (grouped && !this.isTabRailSorted()) {
|
||||
@@ -7797,6 +8158,55 @@ class CodemanApp {
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* A child session's placement, for its row menu: an informational note (where
|
||||
* it sits and why: plain text, never focusable) and, for a child placed by hand, "Follow
|
||||
* parent again". A child follows its parent into the parent's group until it
|
||||
* is moved by hand; a child whose parent is gone stands on its own and is
|
||||
* never re-adopted automatically.
|
||||
*/
|
||||
_tabRefPlacementActions(ref) {
|
||||
// Following is the synced "Spawned tabs follow their parent" setting (off by
|
||||
// default): off, every spawned tab is hand-placed, so there is nothing to say.
|
||||
if (this.loadAppSettingsFromStorage?.()?.spawnedTabsFollowParent !== true) return [];
|
||||
const parents = this._tabLayoutParents();
|
||||
const placement = window.CodemanTabLayout.placementState(this.tabLayout, ref, parents);
|
||||
if (!placement) return [];
|
||||
const parent = this.sessions.get(placement.parentId);
|
||||
const parentName = parent ? this.getSessionName(parent) : placement.parentId;
|
||||
const summary = {
|
||||
inherited: `Follows ${parentName}`,
|
||||
manual: `Placed by hand (parent: ${parentName})`,
|
||||
dangling: 'Parent closed; placed on its own',
|
||||
cycle: 'Parent loop; placed on its own',
|
||||
}[placement.state];
|
||||
const actions = [{ label: summary, note: true }];
|
||||
if (placement.canFollow) {
|
||||
actions.push({
|
||||
label: 'Follow parent again',
|
||||
run: () => this.editTabLayout({ type: 'followParent', ref, parents: this._tabLayoutParents() }, `${ref.kind}:${ref.id}`),
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The group menu's "New session": launch ONE session with the current case and
|
||||
* run mode, placed at the end of this group by the server in the same write
|
||||
* that records it (the group id rides the create request). A collapsed group
|
||||
* expands first so the new tab is visible when it arrives.
|
||||
*/
|
||||
async createSessionInTabGroup(groupId) {
|
||||
if (!this._tabLayoutEditable() || !this.tabLayout.groups.some((group) => group.id === groupId)) return false;
|
||||
if (this._runInFlight) {
|
||||
this.showToast?.('A session is already starting.', 'info');
|
||||
return false;
|
||||
}
|
||||
this.toggleTabGroupCollapsed(groupId, false);
|
||||
await this.run({ count: 1, tabGroupId: groupId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
|
||||
|
||||
/**
|
||||
@@ -7819,10 +8229,11 @@ class CodemanApp {
|
||||
menu.remove();
|
||||
if (!restoreFocus) return;
|
||||
// The trigger may have been re-rendered while the menu was open; find the
|
||||
// live tree item by identity.
|
||||
// live tree item by identity. A flat-rail row (a web tab's menu opens there
|
||||
// too, from ContextMenu or Shift+F10) is a `.session-tab` with role=tab.
|
||||
const rail = this.$('sessionTabs');
|
||||
const item =
|
||||
(trigger?.isConnected && trigger.closest('[role="treeitem"]')) ||
|
||||
(trigger?.isConnected && trigger.closest('[role="treeitem"], .tab-focus-item, .session-tab')) ||
|
||||
[...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
|
||||
item?.focus();
|
||||
}
|
||||
@@ -7837,6 +8248,7 @@ class CodemanApp {
|
||||
const index = groups.findIndex((group) => group.id === groupId);
|
||||
if (index < 0) return false;
|
||||
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
|
||||
{ label: 'New session', run: () => this.createSessionInTabGroup(groupId) },
|
||||
...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []),
|
||||
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
|
||||
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
|
||||
@@ -7848,7 +8260,8 @@ class CodemanApp {
|
||||
|
||||
/** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */
|
||||
openTabWebviewMenu(event, webviewId) {
|
||||
const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId });
|
||||
const ref = { kind: 'webview', id: webviewId };
|
||||
const moves = [...this._tabRefFocusActions(ref), ...this._tabRefMoveActions(ref)];
|
||||
if (!moves.length) {
|
||||
this.showWebviewModal?.(webviewId);
|
||||
return false;
|
||||
@@ -8740,6 +9153,7 @@ class CodemanApp {
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const payload = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
this._notePaneHistory?.(sessionId, payload);
|
||||
const buffer = payload.terminalBuffer;
|
||||
const timing = {
|
||||
trigger: force ? 'full-history-button' : 'full-history-scroll',
|
||||
@@ -8874,9 +9288,14 @@ class CodemanApp {
|
||||
source: payload.source ?? null,
|
||||
fullSize: payload.fullSize ?? 0,
|
||||
retainedBytes: payload.retainedBytes ?? 0,
|
||||
// tmux holds lines beyond a `lines=` window, so fullSize undercounts.
|
||||
historyLinesCut: !!payload.historyLinesCut,
|
||||
// Set once a full-history pull has been refused as a downgrade: the
|
||||
// browser holds more than the server can return, so there is no more.
|
||||
exhausted: !!payload.exhausted,
|
||||
// tmux scrollback above the frame (null = not reported). 0 is a pane with
|
||||
// nothing a pull could add, which hides the notice outright.
|
||||
paneHistoryLines: payload.paneHistoryLines ?? null,
|
||||
});
|
||||
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
|
||||
}
|
||||
@@ -8884,9 +9303,45 @@ class CodemanApp {
|
||||
/** Drop banner state for a session that is going away. */
|
||||
_clearHistoryTruncation(sessionId) {
|
||||
this._historyTruncation?.delete(sessionId);
|
||||
this._historyNoticeDismissed?.delete(sessionId);
|
||||
this._paneHistoryLines?.delete(sessionId);
|
||||
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember how much scrollback tmux holds for a session's pane, from any
|
||||
* terminal response. Called whether or not the response was written, since
|
||||
* it describes the PANE, not the payload. A response without the field (a
|
||||
* byte-history fallback, an older server) forgets it, so the next tab switch
|
||||
* goes back to the bounded tail: unknown never counts as empty.
|
||||
*/
|
||||
_notePaneHistory(sessionId, payload) {
|
||||
if (!sessionId) return;
|
||||
const lines = payload?.paneHistoryLines;
|
||||
if (typeof lines === 'number' && Number.isFinite(lines) && lines >= 0) {
|
||||
(this._paneHistoryLines ||= new Map()).set(sessionId, lines);
|
||||
} else {
|
||||
this._paneHistoryLines?.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the partial-history notice up for `sessionId`, or retire it (null).
|
||||
*
|
||||
* The notice is LAZY: a tail replay is truncated on nearly every tab switch,
|
||||
* and a bar over the top rows on every switch described history the user had
|
||||
* not reached for. It now waits for the scroll gesture that reaches the top of
|
||||
* the browser's buffer (the moment the missing part matters, and the same
|
||||
* gesture that already re-pulls history), and goes away again once the user
|
||||
* scrolls back down to live output. A tab switch retires it (selectSession).
|
||||
*/
|
||||
_setHistoryNoticeRevealed(sessionId) {
|
||||
const next = sessionId || null;
|
||||
if ((this._historyNoticeRevealedFor ?? null) === next) return;
|
||||
this._historyNoticeRevealedFor = next;
|
||||
this._renderHistoryTruncationBanner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint the partial-history banner for the active session.
|
||||
*
|
||||
@@ -8896,13 +9351,22 @@ class CodemanApp {
|
||||
* - recoverable → offer to load the rest
|
||||
* - exhausted → say so plainly, offer nothing
|
||||
* - at the limit → the full capture ITSELF hit the byte ceiling
|
||||
*
|
||||
* Shown only while revealed (`_setHistoryNoticeRevealed`: the user scrolled
|
||||
* to the top of this tab's buffer) and never again for a session whose notice
|
||||
* the user dismissed on this page.
|
||||
*/
|
||||
_renderHistoryTruncationBanner() {
|
||||
const bar = document.getElementById('historyTruncationBar');
|
||||
if (!bar) return;
|
||||
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
|
||||
const sessionId = this.activeSessionId;
|
||||
const state = sessionId ? this._historyTruncation?.get(sessionId) : null;
|
||||
const notice = computeHistoryTruncationNotice(state || {});
|
||||
if (!notice.visible) {
|
||||
if (
|
||||
!notice.visible ||
|
||||
this._historyNoticeRevealedFor !== sessionId ||
|
||||
this._historyNoticeDismissed?.has(sessionId)
|
||||
) {
|
||||
bar.hidden = true;
|
||||
return;
|
||||
}
|
||||
@@ -8935,6 +9399,9 @@ class CodemanApp {
|
||||
dismiss.setAttribute('aria-label', 'Dismiss history notice');
|
||||
dismiss.textContent = '×';
|
||||
dismiss.onclick = () => {
|
||||
// Sticky for this session until the page reloads: a dismissed notice used
|
||||
// to come straight back on the next tab switch.
|
||||
(this._historyNoticeDismissed ||= new Set()).add(sessionId);
|
||||
bar.hidden = true;
|
||||
};
|
||||
bar.appendChild(dismiss);
|
||||
@@ -9048,7 +9515,9 @@ class CodemanApp {
|
||||
this._activateFileBrowserSession?.(sessionId);
|
||||
// Repaint the partial-history banner for the tab being switched TO. The
|
||||
// replay paths refresh it when their fetch lands; without this the previous
|
||||
// session's notice stays on screen until then (#258).
|
||||
// session's notice stays on screen until then (#258). The switch lands at
|
||||
// live output, so the notice waits for a scroll to the top again.
|
||||
this._historyNoticeRevealedFor = null;
|
||||
this._renderHistoryTruncationBanner();
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
// Narrow SSE filter to the active session — server stops streaming
|
||||
@@ -9317,16 +9786,41 @@ class CodemanApp {
|
||||
// TUI sessions still get one canonical full replay per page (COD-47/#205).
|
||||
// A shell can retain hundreds of thousands of plain scrollback lines, so
|
||||
// automatically replaying all of them makes tab selection scale with the
|
||||
// entire session. Load its bounded 1MB tail first; the existing truncation
|
||||
// banner action fetches ?full=1 when the user explicitly asks for it.
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
// entire session. Load a bounded window of tmux's RENDERED history first
|
||||
// (SHELL_LOAD_QUERY: never the raw byte recording, whose repaints replay
|
||||
// wrong at another pane height); the existing truncation banner action
|
||||
// fetches ?full=1 when the user explicitly asks for it.
|
||||
//
|
||||
// A TUI pane whose last capture reported NO tmux scrollback (fullscreen
|
||||
// claude: it lives in the alternate screen and keeps its transcript itself)
|
||||
// takes `full=1` on every switch, not only the first. For such a pane that
|
||||
// capture IS the visible frame, a few KB, while the tail is 1 MiB of the
|
||||
// byte stream's old repaints. Measured on 11 live fullscreen claude panes:
|
||||
// the tail took 250-1070 ms on the server and 70-510 ms to parse, and
|
||||
// rendered one frame repeated (450 rows, 44 distinct); `full=1` took
|
||||
// 150-330 ms and returned 0.9-5.6 KB. It also matches what a page load
|
||||
// already shows, and an empty local buffer is what lets
|
||||
// _maybePageCliTranscript send a wheel to the CLI's own transcript. The
|
||||
// response re-reports the count, so a pane that starts keeping history
|
||||
// (claude switched to its inline view) goes back to the tail on the next
|
||||
// switch.
|
||||
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
|
||||
// alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
const shellSession = session?.mode === 'shell';
|
||||
const paneKeepsNoHistory =
|
||||
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
|
||||
let capture;
|
||||
try {
|
||||
capture = await this._fetchTerminalCapture(
|
||||
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: shellSession
|
||||
? `/api/sessions/${sessionId}/terminal?${SHELL_LOAD_QUERY}`
|
||||
: tailUrl,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -9349,6 +9843,7 @@ class CodemanApp {
|
||||
}
|
||||
const data = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
this._notePaneHistory?.(sessionId, data);
|
||||
// How this load must end, decided here because `chunkedTerminalWrite` is
|
||||
// what actually ends it for a non-empty buffer. A tmux pane capture is a
|
||||
// point-in-time frame, so nothing that reached the browser after the
|
||||
@@ -9649,8 +10144,9 @@ class CodemanApp {
|
||||
//
|
||||
// UNREACHABLE as written, and kept for the invariant rather than the
|
||||
// branch. A `useFullHistory` pass sends `full=1`, and the route answers
|
||||
// `full=1` with `mux-full-history` or `history`, never `mux-visible`
|
||||
// (see the source ladder in session-routes.ts), so the gate above
|
||||
// a `full=1` pass without `alt=recording` (every `useFullHistory` pass;
|
||||
// only shell loads send it) with `mux-full-history` or `history`, never
|
||||
// `mux-visible` (see the source ladder in session-routes.ts), so the gate above
|
||||
// already rules out every pass that consumed the flag. Do not read this
|
||||
// line as evidence that a page load retries: it does not, and the test
|
||||
// suite pins that it does not.
|
||||
@@ -9793,8 +10289,8 @@ class CodemanApp {
|
||||
// Remove from tab order
|
||||
const orderIndex = this.sessionOrder.indexOf(sessionId);
|
||||
if (orderIndex !== -1) {
|
||||
// Not saved: the server's deletion already re-projected and broadcast the order.
|
||||
this.sessionOrder.splice(orderIndex, 1);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
this.terminalBuffers.delete(sessionId);
|
||||
this.terminalBufferCache.delete(sessionId);
|
||||
@@ -9841,9 +10337,10 @@ class CodemanApp {
|
||||
delete this.respawnActionLogs[sessionId];
|
||||
}
|
||||
|
||||
/** Resolves true once the session is gone from the server, false when it is still there. */
|
||||
async closeSession(sessionId, killMux = true) {
|
||||
// Already on its way out (a repeated click, the mux panel racing the tab).
|
||||
if (this._closingSessions.has(sessionId)) return;
|
||||
if (this._closingSessions.has(sessionId)) return false;
|
||||
// The tab goes FIRST and the server is asked after. The kill takes the server
|
||||
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
|
||||
// there that long after "Kill" read as a click that did nothing. A refused
|
||||
@@ -9921,7 +10418,7 @@ class CodemanApp {
|
||||
} else {
|
||||
this.showToast('Tab hidden, tmux still running', 'info');
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Still on the server (or the server is unreachable, in which case the resync
|
||||
@@ -9936,6 +10433,7 @@ class CodemanApp {
|
||||
this.renderSessionTabs();
|
||||
}
|
||||
this.showToast('Failed to close session', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Request confirmation before closing a session
|
||||
@@ -9950,6 +10448,20 @@ class CodemanApp {
|
||||
const sessionNameEl = document.getElementById('closeConfirmSessionName');
|
||||
sessionNameEl.textContent = name;
|
||||
|
||||
// A session in a Codeman-managed git worktree also offers to remove the worktree
|
||||
// (git worktree remove, never forced) once the session is gone. Read now, before the
|
||||
// session leaves this.sessions.
|
||||
const worktree = this._managedWorktreeOfSession?.(session) || null;
|
||||
this.pendingCloseWorktree = worktree;
|
||||
const wtOption = document.getElementById('closeConfirmWorktree');
|
||||
if (wtOption) {
|
||||
wtOption.hidden = !worktree;
|
||||
const wtName = document.getElementById('closeConfirmWorktreeName');
|
||||
if (wtName) wtName.textContent = worktree ? worktree.name : '';
|
||||
const wtBranch = document.getElementById('closeConfirmWorktreeBranch');
|
||||
if (wtBranch) wtBranch.checked = false;
|
||||
}
|
||||
|
||||
// Update kill button text based on session mode
|
||||
const killTitle = document.getElementById('closeConfirmKillTitle');
|
||||
if (killTitle) {
|
||||
@@ -9977,15 +10489,24 @@ class CodemanApp {
|
||||
|
||||
cancelCloseSession() {
|
||||
this.pendingCloseSessionId = null;
|
||||
this.pendingCloseWorktree = null;
|
||||
document.getElementById('closeConfirmModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async confirmCloseSession(killMux = true) {
|
||||
/**
|
||||
* `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only
|
||||
* after the server confirms the session is gone (a live session inside is refused anyway),
|
||||
* and only ever through `git worktree remove` without --force.
|
||||
*/
|
||||
async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) {
|
||||
const sessionId = this.pendingCloseSessionId;
|
||||
const worktree = removeWorktree ? this.pendingCloseWorktree : null;
|
||||
const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked;
|
||||
this.cancelCloseSession();
|
||||
|
||||
if (sessionId) {
|
||||
await this.closeSession(sessionId, killMux);
|
||||
const gone = await this.closeSession(sessionId, killMux);
|
||||
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
|
||||
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
|
||||
`</div>`;
|
||||
} else if (item.answerInTerminal) {
|
||||
// Supervised-hook CLIs (DeepSeek Harness): keystrokes are unverifiable, so
|
||||
// Approve/Deny would hit the answer route and fail. Offer only Open session.
|
||||
actions = `<span class="approval-terminal-hint" data-i18n-skip>Answer in the terminal</span>`;
|
||||
} else if (item.options && item.options.length) {
|
||||
actions = item.options
|
||||
.map(
|
||||
|
||||
@@ -116,6 +116,21 @@ const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / bro
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||
// A shell's automatic loads (tab select, drop recovery, a tile's load) read
|
||||
// tmux's RENDERED history, never the raw byte recording: that recording holds
|
||||
// full-screen repaints (an ssh'd tmux, less, a TUI) drawn for the pane size of
|
||||
// the moment, and replayed into a terminal of another height each repaint
|
||||
// scrolls its top row into scrollback, so one line shows up dozens of times.
|
||||
// `lines=` bounds the synchronous capture-pane (~8 ms per 1000 lines measured,
|
||||
// so ~0.1 s here); the server reports a cut as 'tail', and scroll-to-top pulls
|
||||
// the older lines as before. `alt=recording`: a pane in the alternate screen (a
|
||||
// nested tmux, as in remote SSH and Docker cases, but also vim or less while
|
||||
// open) gains no tmux history, so the server loads it the old way, visible
|
||||
// frame over the tailed byte recording.
|
||||
// A grid tile appends its own `lines=` to SHELL_LOAD_BASE_QUERY.
|
||||
const SHELL_LOAD_HISTORY_LINES = 10000;
|
||||
const SHELL_LOAD_BASE_QUERY = `full=1&tail=${TERMINAL_TAIL_SIZE}&alt=recording`;
|
||||
const SHELL_LOAD_QUERY = `${SHELL_LOAD_BASE_QUERY}&lines=${SHELL_LOAD_HISTORY_LINES}`;
|
||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
|
||||
@@ -709,6 +724,45 @@ function computeConnectionLossUi(input) {
|
||||
};
|
||||
}
|
||||
|
||||
// Session Options → Respawn → "Prompt cache": one sentence of the main
|
||||
// conversation's cache state as Claude Code reports it on the statusline
|
||||
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
|
||||
// because the row refreshes on SSE, not on a timer, and a countdown would sit
|
||||
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
|
||||
// session's `statusLineExporter`: a launch without the exporter never reports, so
|
||||
// it says why instead of "not reported yet" forever.
|
||||
const PROMPT_CACHE_NOT_COLLECTED = {
|
||||
'collection-off':
|
||||
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
|
||||
'workspace-statusline':
|
||||
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
|
||||
remote: 'Not collected for remote and Docker sessions.',
|
||||
};
|
||||
function formatPromptCacheStatus(cache, now, statusLine) {
|
||||
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
|
||||
if (!cache || typeof cache.warm !== 'boolean') {
|
||||
return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
|
||||
}
|
||||
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
|
||||
const stake = cache.recacheTokensIfCold;
|
||||
const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
|
||||
const parts = [];
|
||||
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
|
||||
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
|
||||
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
|
||||
if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`);
|
||||
} else {
|
||||
parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.');
|
||||
}
|
||||
// A compaction or a tool-result clearing leaves no re-cache stake to show until the next
|
||||
// request; naming when it happened explains the gap. Never an alert.
|
||||
if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`);
|
||||
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
|
||||
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// SSE staleness policy: is this stream a zombie?
|
||||
//
|
||||
// An EventSource that stops delivering does not always error. A proxy that
|
||||
@@ -1447,6 +1501,9 @@ if (typeof window !== 'undefined') {
|
||||
compute: computeSseStale,
|
||||
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
|
||||
};
|
||||
window.CodemanPromptCache = {
|
||||
format: formatPromptCacheStatus,
|
||||
};
|
||||
window.CodemanSessionOrder = {
|
||||
RANK: SESSION_ACTIVITY_RANK,
|
||||
anchor: sessionActivityAnchor,
|
||||
@@ -1507,6 +1564,15 @@ function scheduleBackground(fn) {
|
||||
// but server-sent terminal buffers may still contain markers from Claude CLI.
|
||||
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
||||
|
||||
// OSC 52 clipboard writes stripping — a replay re-feeds the raw byte history, and
|
||||
// xterm parses it after chunkedTerminalWrite has already released the load
|
||||
// gate, so an OSC 52 left in would re-copy old text on every tab switch. The
|
||||
// 8-bit C1 forms (OSC \x9d, ST \x9c) too: xterm.js still parses those.
|
||||
const OSC52_STRIP_RE = /(?:\x1b\]|\x9d)52;[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g;
|
||||
// The same OSC 52 text arriving again within this window is one copy delivered
|
||||
// several ways (direct, tmux load-buffer -w, tmux set-clipboard forwarding).
|
||||
const OSC52_DEDUPE_MS = 1000;
|
||||
|
||||
// Built-in respawn configuration presets
|
||||
const BUILTIN_RESPAWN_PRESETS = [
|
||||
{
|
||||
@@ -1632,6 +1698,7 @@ const SSE_EVENTS = {
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||||
SESSION_PROMPT_CACHE: 'session:promptCache',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
@@ -1860,7 +1927,10 @@ function escapeHtml(text) {
|
||||
function formatHistoryBytes(bytes) {
|
||||
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
|
||||
if (n < 1024) return 'less than 1 KB';
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
|
||||
// just under 1 MiB and used to print as "1024 KB".
|
||||
const kb = Math.round(n / 1024);
|
||||
if (kb < 1024) return `${kb} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
@@ -1874,12 +1944,27 @@ function formatHistoryBytes(bytes) {
|
||||
* - atCeiling: the FULL capture itself hit the byte ceiling
|
||||
* - exhausted: a full pull was refused as a downgrade, so this is all there is
|
||||
*
|
||||
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
|
||||
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
|
||||
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
|
||||
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
|
||||
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
|
||||
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
|
||||
* and the button only ever ended in the downgrade refusal. Nothing to offer,
|
||||
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
|
||||
*
|
||||
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
|
||||
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
|
||||
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
|
||||
* paneHistoryLines?: number|null, historyLinesCut?: boolean}} state
|
||||
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
|
||||
*/
|
||||
function computeHistoryTruncationNotice(state = {}) {
|
||||
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
|
||||
const historyLines =
|
||||
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
|
||||
? Math.max(0, state.paneHistoryLines)
|
||||
: null;
|
||||
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
|
||||
|
||||
const retained = Math.max(0, state.retainedBytes || 0);
|
||||
const dropped = Math.max(0, (state.fullSize || 0) - retained);
|
||||
@@ -1902,9 +1987,22 @@ function computeHistoryTruncationNotice(state = {}) {
|
||||
canLoadMore: false,
|
||||
};
|
||||
}
|
||||
// Name what a pull can actually return when the server said: the byte gap
|
||||
// counts repaints and redraw bloat, and overstates it many times over.
|
||||
// Without that count, a `lines=`-bounded capture is cut by tmux before any
|
||||
// bytes are counted, so its fullSize is the window itself and the remainder's
|
||||
// size is unknown: say that more exists without inventing an amount. That
|
||||
// holds even when `tail` also cut bytes off the window (`dropped`), since tmux
|
||||
// still holds more beyond it (`historyLinesCut`, set by the server).
|
||||
const remainder =
|
||||
dropped > 0 && !state.historyLinesCut ? `${formatHistoryBytes(dropped)} more` : 'Earlier output';
|
||||
const more =
|
||||
historyLines !== null
|
||||
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
|
||||
: `${remainder} may still be retained.`;
|
||||
return {
|
||||
visible: true,
|
||||
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
|
||||
message: `Showing the most recent ${shown} of this session. ${more}`,
|
||||
canLoadMore: true,
|
||||
};
|
||||
}
|
||||
@@ -1949,7 +2047,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
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|xlsx|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|markdown|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|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -1961,13 +2059,18 @@ function absoluteFilePathPattern() {
|
||||
* points at goes to the tail/log viewer, which is the right home for a growing
|
||||
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
|
||||
*
|
||||
* Markdown is here too (owner decision 2026-10-13): an agent's `.md` is a
|
||||
* document to read, so the preview renders it (its MD pill gives the source)
|
||||
* instead of the log viewer streaming it as raw text. Other text (.log, .txt,
|
||||
* .json, code) keeps the log viewer, which follows a file still being written.
|
||||
*
|
||||
* The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||
* (src/attachment-registry.ts, the single source) — they diverged once and an
|
||||
* in-workspace `.m4a` opened as binary noise in the log viewer while the same
|
||||
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
|
||||
*/
|
||||
const FILE_PREVIEW_EXTENSIONS = new Set(
|
||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
||||
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus md markdown').split(' ')
|
||||
);
|
||||
|
||||
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
|
||||
@@ -1976,6 +2079,155 @@ function previewsInFileViewer(filePath) {
|
||||
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
|
||||
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
|
||||
* or a bare `look_montage.png`. Same extension list and body as
|
||||
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
|
||||
* equal).
|
||||
*
|
||||
* Without a known root to anchor on, the START is what has to be unambiguous:
|
||||
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
|
||||
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
|
||||
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
|
||||
* a URL. The path itself is group 2. Its first character is never `/`: absolute
|
||||
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
|
||||
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
|
||||
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
|
||||
* the file, not the punctuation around it.
|
||||
*
|
||||
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|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|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
|
||||
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||||
|
||||
/**
|
||||
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
|
||||
* then `~/` and relative ones, then folder paths — each only where it overlaps
|
||||
* neither an earlier one nor a URL.
|
||||
*
|
||||
* The one entry point for both consumers (terminal link provider, response
|
||||
* viewer), so a path form is clickable in both or in neither.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
|
||||
* `text`; `folder` marks a folder path (folder grid) rather than a file.
|
||||
*/
|
||||
function findFilePathLinks(text) {
|
||||
const value = String(text || '');
|
||||
const found = [];
|
||||
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
|
||||
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
|
||||
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
|
||||
// (`file:///home/x.png` is not one of them: its path is the file.)
|
||||
const urls = [];
|
||||
const urlPattern = /\bhttps?:\/\/\S+/gi;
|
||||
let match;
|
||||
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
|
||||
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
|
||||
|
||||
const absolute = absoluteFilePathPattern();
|
||||
while ((match = absolute.exec(value)) !== null) {
|
||||
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
|
||||
}
|
||||
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
|
||||
while ((match = relative.exec(value)) !== null) {
|
||||
const path = match[2];
|
||||
const index = match.index + match[1].length;
|
||||
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
|
||||
found.push({ path, index });
|
||||
}
|
||||
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
|
||||
while ((match = folders.exec(value)) !== null) {
|
||||
const path = folderLinkPath(match[2]);
|
||||
const index = match.index + match[1].length;
|
||||
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
|
||||
found.push({ path, index, folder: true });
|
||||
}
|
||||
return found.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
|
||||
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
|
||||
* trailing slash (`builds/captures/`). Same boundary in front as the relative
|
||||
* file pattern (group 1), the candidate itself is group 2 and goes through
|
||||
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
|
||||
* File links are found first and win any overlap. A folder path ends at `:`,
|
||||
* which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
|
||||
* the `://` lookahead stops at the next colon instead of rescanning the token from
|
||||
* every one (quadratic on `a:a:a:…//`).
|
||||
*
|
||||
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const FOLDER_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,:\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*)/g;
|
||||
|
||||
/**
|
||||
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
|
||||
*
|
||||
* Sentence punctuation after the path is not part of it. A path ending in `/` is
|
||||
* a folder; otherwise only a rooted or `~/` path whose last segment has no
|
||||
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
|
||||
* without a slash at the end is prose, not a folder.
|
||||
*/
|
||||
function folderLinkPath(candidate) {
|
||||
// `$` and `#` too: a shell prompt (`user@host:~/proj$ `, `root@c:/app# `) ends
|
||||
// its cwd with one. A folder whose name really ends in one links one short.
|
||||
const path = String(candidate || '').replace(/[.,:;!?'"$#]+$/, '');
|
||||
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
|
||||
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
|
||||
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
|
||||
const last = path.slice(path.lastIndexOf('/') + 1);
|
||||
return /^\.?[^.]+$/.test(last) ? path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a clicked path points, as the preview routes need it.
|
||||
*
|
||||
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
|
||||
* session's HOST, which only the server knows (a remote case's home is on the
|
||||
* remote machine), so the server expands it. A relative path is resolved
|
||||
* against the session's working directory, `.` and `..` collapsed, so a
|
||||
* `../other/x.png` ends up outside the workspace and routes like any other
|
||||
* external path. Without a working directory it is returned as is.
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {string} [workingDir]
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveLinkedFilePath(filePath, workingDir) {
|
||||
const path = String(filePath || '');
|
||||
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
|
||||
const parts = [];
|
||||
for (const part of `${workingDir}/${path}`.split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') parts.pop();
|
||||
else parts.push(part);
|
||||
}
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions the file preview renders as a PAGE, from the sandboxed
|
||||
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
|
||||
* HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
|
||||
* the two equal). The terminal sends a click on one to the preview, never to
|
||||
* the log viewer, and the preview renders it with a Page pill back to source.
|
||||
*/
|
||||
const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
|
||||
|
||||
/** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
|
||||
function rendersAsPage(filePath) {
|
||||
const ext = String(filePath || '').split('.').pop().toLowerCase();
|
||||
return HTML_PAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
|
||||
@@ -2080,6 +2332,134 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
|
||||
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||||
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
|
||||
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
|
||||
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
|
||||
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
|
||||
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
|
||||
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
|
||||
const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
|
||||
|
||||
/**
|
||||
* Whether the line `nextText` carries on a file path `prevText` broke off.
|
||||
*
|
||||
* Answers `'join'` when the path ends (reaches a known extension) on the next
|
||||
* line, `'chain'` when the next line is nothing but more of the path (a path
|
||||
* spanning three or more lines; only worth keeping once a later line ends it),
|
||||
* and `null` otherwise. The broken-off token must already look like a path in
|
||||
* progress: it contains a `/`, ends on a path character and does not yet end
|
||||
* on an extension. A next line that opens a NEW path is never glued on: a rooted
|
||||
* one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
|
||||
* top directory the broken token did (the next entry of a listing).
|
||||
*/
|
||||
function pathContinuation(prevText, nextText) {
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
|
||||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||||
const next = String(nextText || '').replace(/^\s+/, '');
|
||||
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
|
||||
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
|
||||
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
|
||||
// followed by `src/index.ts` is two entries, not one path broken after `src/components`.
|
||||
// A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
|
||||
if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
|
||||
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
|
||||
return head === next.replace(/\s+$/, '') ? 'chain' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link terminalLogicalLine}, extended across the line breaks a program puts
|
||||
* INSIDE a file path. Used by the link provider only; touch selection keeps the
|
||||
* plain logical line.
|
||||
*
|
||||
* Claude Code wraps its tool output itself, a few columns short of the terminal
|
||||
* edge, so neither continuation signal of terminalLogicalLine fires (no
|
||||
* `isWrapped`, the row does not reach the last column) and a long path was only
|
||||
* linked up to the break:
|
||||
*
|
||||
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
|
||||
* 026-10-10/ghost1b_gameplay.png err=0 …
|
||||
*
|
||||
* A following logical line is glued on (its indent dropped, cell mapping kept
|
||||
* exact) when {@link pathContinuation} says it carries the path on and the row it
|
||||
* broke off is at least as long as the row it continues on. Bounded by the same
|
||||
* `maxRows` as the logical line.
|
||||
*
|
||||
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
|
||||
*/
|
||||
function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
const base = terminalLogicalLine(buffer, row, cols, maxRows);
|
||||
if (!base) return null;
|
||||
const bound = Math.max(1, maxRows || 12);
|
||||
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
|
||||
const rowsOf = (line) => line.endRow - line.startRow + 1;
|
||||
// A program breaking a long token fills the row to its wrap width, and no
|
||||
// later row of that block is wider. So the row a path broke off is at least as
|
||||
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
|
||||
// to a longer ` file.png written` below it.
|
||||
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
|
||||
const continues = (prev, next, prevText) =>
|
||||
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
|
||||
|
||||
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
|
||||
const extend = (first) => {
|
||||
const parts = [first];
|
||||
let text = first.text;
|
||||
let rows = rowsOf(first);
|
||||
let confirmed = 1;
|
||||
for (;;) {
|
||||
const next = lineAt(parts[parts.length - 1].endRow + 1);
|
||||
if (!next || rows + rowsOf(next) > bound) break;
|
||||
const kind = continues(parts[parts.length - 1], next, text);
|
||||
if (!kind) break;
|
||||
parts.push(next);
|
||||
text += next.text.replace(/^\s+/, '');
|
||||
rows += rowsOf(next);
|
||||
if (kind === 'join') confirmed = parts.length;
|
||||
}
|
||||
return parts.slice(0, confirmed);
|
||||
};
|
||||
|
||||
// The hovered row may be the second or third line of a broken path: walk back
|
||||
// to where it could start, then let the forward pass decide what really joins.
|
||||
let first = base;
|
||||
let rows = rowsOf(base);
|
||||
while (first.startRow > 0) {
|
||||
const prev = lineAt(first.startRow - 1);
|
||||
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
|
||||
first = prev;
|
||||
rows += rowsOf(prev);
|
||||
}
|
||||
let parts = extend(first);
|
||||
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
|
||||
if (parts.length === 1) return parts[0];
|
||||
|
||||
const segments = [];
|
||||
let text = '';
|
||||
for (const [i, part] of parts.entries()) {
|
||||
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
|
||||
segments.push({ part, textStart: text.length, skip });
|
||||
text += part.text.slice(skip);
|
||||
}
|
||||
const offsetToCell = (offset) => {
|
||||
for (let i = segments.length - 1; i >= 0; i--) {
|
||||
const seg = segments[i];
|
||||
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
|
||||
}
|
||||
return segments[0].part.offsetToCell(offset);
|
||||
};
|
||||
const cellToOffset = (targetRow, targetCol) => {
|
||||
for (const seg of segments) {
|
||||
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
|
||||
const offset = seg.part.cellToOffset(targetRow, targetCol);
|
||||
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -2992,8 +3372,16 @@ function describeSessionHarness(session, catalog) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanFilePaths = {
|
||||
absoluteFilePathPattern,
|
||||
findFilePathLinks,
|
||||
resolveLinkedFilePath,
|
||||
previewsInFileViewer,
|
||||
FILE_PREVIEW_EXTENSIONS,
|
||||
rendersAsPage,
|
||||
HTML_PAGE_EXTENSIONS,
|
||||
};
|
||||
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
|
||||
window.CodemanUrlSession = { sessionIdFromFragment };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
|
||||
@@ -4448,6 +4448,7 @@ var GestureController = class {
|
||||
|
||||
// packages/gesture-control/src/codeman/entry.ts
|
||||
var TAB_SELECTOR = ".session-tab";
|
||||
var TILE_SELECTOR = "#tileGrid .tile";
|
||||
var PANEL_SELECTOR = ".cg-float";
|
||||
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
|
||||
var DOCK_SELECTOR = ".session-tabs";
|
||||
@@ -4457,6 +4458,8 @@ var FLOAT_W = 640;
|
||||
var FLOAT_H = 420;
|
||||
var DETACH_PULL_PX = 70;
|
||||
var TAP_CANCEL_PX = 45;
|
||||
var TILE_MOVE_PX = 40;
|
||||
var TILE_GHOST_W = 280;
|
||||
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
|
||||
var GestureBridge = class {
|
||||
constructor() {
|
||||
@@ -4533,7 +4536,7 @@ var GestureBridge = class {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add("on");
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
this.status.textContent = "on: pinch a tab, tile, window, or button";
|
||||
} catch (err) {
|
||||
const msg = describeError(err);
|
||||
this.status.textContent = `failed: ${msg}`;
|
||||
@@ -4588,6 +4591,30 @@ var GestureBridge = class {
|
||||
this.status.textContent = "moving window";
|
||||
return;
|
||||
}
|
||||
const tileId = window.app?.tileAtPoint?.(x2, y2) ?? null;
|
||||
const tileEl = tileId ? this.hitClosest(x2, y2, TILE_SELECTOR) : null;
|
||||
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
|
||||
const rect = tileEl.getBoundingClientRect();
|
||||
const ghost = this.tileGhost(tileEl, rect);
|
||||
document.body.append(ghost);
|
||||
tileEl.classList.add("tile--dragging");
|
||||
this.grabs.set(hand, {
|
||||
kind: "tile",
|
||||
id: tileId,
|
||||
el: tileEl,
|
||||
ghost,
|
||||
offsetX: Math.max(0, Math.min(x2 - rect.left, rect.width)),
|
||||
offsetY: Math.max(0, Math.min(y2 - rect.top, rect.height)),
|
||||
ox: x2,
|
||||
oy: y2,
|
||||
armed: false,
|
||||
target: null,
|
||||
out: false
|
||||
});
|
||||
this.positionGhost(ghost, x2, y2);
|
||||
this.status.textContent = "moving tile";
|
||||
return;
|
||||
}
|
||||
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
if (tab && id) {
|
||||
@@ -4599,7 +4626,7 @@ var GestureBridge = class {
|
||||
ghost.style.height = `${rect.height}px`;
|
||||
document.body.append(ghost);
|
||||
tab.classList.add("cg-grabbed");
|
||||
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
|
||||
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false, target: null });
|
||||
this.positionGhost(ghost, x2, y2);
|
||||
return;
|
||||
}
|
||||
@@ -4613,13 +4640,30 @@ var GestureBridge = class {
|
||||
}
|
||||
onDrag(hand, x2, y2) {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === "tile") {
|
||||
this.positionGhost(grab.ghost, x2, y2);
|
||||
if (!grab.armed && Math.hypot(x2 - grab.ox, y2 - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
|
||||
if (!grab.armed) return;
|
||||
const app = window.app;
|
||||
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
|
||||
this.setTileTarget(grab, target);
|
||||
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x2, y2);
|
||||
grab.out = out;
|
||||
grab.ghost.classList.toggle("cg-armed", !!target);
|
||||
grab.ghost.classList.toggle("cg-out", out);
|
||||
this.status.textContent = target ? target.kind === "tile" ? "release to swap tiles" : "release to move the tile here" : out ? "release to open in a new window" : "moving tile";
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "tab") {
|
||||
this.positionGhost(grab.ghost, x2, y2);
|
||||
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
|
||||
if (pulled !== grab.armed) {
|
||||
const target = pulled ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
|
||||
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
|
||||
this.setTileTarget(grab, target);
|
||||
if (pulled !== grab.armed || targetChanged) {
|
||||
grab.armed = pulled;
|
||||
grab.ghost.classList.toggle("cg-armed", pulled);
|
||||
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
|
||||
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4641,16 +4685,40 @@ var GestureBridge = class {
|
||||
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
this.status.textContent = "on: pinch a tab, tile, window, or button";
|
||||
}
|
||||
}
|
||||
onDrop(hand, x2, y2) {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === "tile") {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove("tile--dragging");
|
||||
this.setTileTarget(grab, null);
|
||||
const app = window.app;
|
||||
if (!grab.armed) {
|
||||
this.flash("cancelled");
|
||||
return;
|
||||
}
|
||||
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
|
||||
if (target) {
|
||||
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? "moved tile" : "cancelled");
|
||||
} else if (grab.out && !app?.isOverTileGrid?.(x2, y2)) {
|
||||
const opened = app?.detachTileAtPoint?.(grab.id, x2, y2, { offsetX: grab.offsetX, offsetY: grab.offsetY });
|
||||
this.flash(opened ? "opened in a new window" : "pop-out blocked: allow popups for this site");
|
||||
} else {
|
||||
this.flash("cancelled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "tab") {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
if (grab.armed) this.floatPanel(grab.id, x2, y2);
|
||||
this.setTileTarget(grab, null);
|
||||
const target = grab.armed ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
|
||||
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? "tiled" : "cancelled");
|
||||
else if (grab.armed) this.floatPanel(grab.id, x2, y2);
|
||||
else this.flash("cancelled");
|
||||
return;
|
||||
}
|
||||
@@ -4775,11 +4843,41 @@ var GestureBridge = class {
|
||||
ghost.style.left = `${x2}px`;
|
||||
ghost.style.top = `${y2}px`;
|
||||
}
|
||||
/** A small copy of a tile to follow the hand: its header (a copy: no
|
||||
* listeners come along) over an empty body, in the tile's proportions. */
|
||||
tileGhost(tileEl, rect) {
|
||||
const ghost = el("div", "cg-ghost cg-tile-ghost");
|
||||
const width = Math.min(TILE_GHOST_W, rect.width);
|
||||
ghost.style.width = `${width}px`;
|
||||
ghost.style.height = `${Math.max(48, Math.round(width * rect.height / Math.max(1, rect.width)))}px`;
|
||||
const header = tileEl.querySelector(".tile-header");
|
||||
if (header) {
|
||||
const copy = header.cloneNode(true);
|
||||
copy.removeAttribute("draggable");
|
||||
ghost.append(copy);
|
||||
}
|
||||
ghost.append(el("div", "cg-tile-ghost-body"));
|
||||
return ghost;
|
||||
}
|
||||
/** Highlights the tile or empty cell a carried session would drop onto
|
||||
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
|
||||
setTileTarget(grab, target) {
|
||||
if (grab.target?.el !== target?.el) {
|
||||
grab.target?.el.classList.remove("tile--drop-target");
|
||||
target?.el.classList.add("tile--drop-target");
|
||||
}
|
||||
grab.target = target;
|
||||
}
|
||||
cancelAllGrabs() {
|
||||
for (const grab of this.grabs.values()) {
|
||||
if (grab.kind === "tab") {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === "tile") {
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove("tile--dragging");
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === "panel") {
|
||||
grab.panel.el.style.pointerEvents = "";
|
||||
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
||||
@@ -4791,6 +4889,7 @@ var GestureBridge = class {
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.clear();
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
|
||||
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t2) => t2.classList.remove("tile--dragging"));
|
||||
}
|
||||
onStatus(fps, hands) {
|
||||
const { width, height } = this.canvas;
|
||||
@@ -4863,6 +4962,13 @@ function injectStyles() {
|
||||
outline: 2px solid #38bdf8; outline-offset: -2px;
|
||||
}
|
||||
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
|
||||
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
|
||||
.cg-tile-ghost {
|
||||
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
|
||||
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
|
||||
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
|
||||
.cg-dock {
|
||||
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
|
||||
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
|
||||
|
||||
@@ -93,6 +93,16 @@
|
||||
'Move down': '下移',
|
||||
'Move to Ungrouped': '移到未分组',
|
||||
'Move to new group': '移到新分组',
|
||||
'New session': '新建会话',
|
||||
'A session is already starting.': '已有会话正在启动。',
|
||||
'Follow parent again': '重新跟随父会话',
|
||||
'Parent closed; placed on its own': '父会话已关闭;单独放置',
|
||||
'Parent loop; placed on its own': '父会话形成循环;单独放置',
|
||||
// The Focus section pinned at the top of the vertical rail (shortcuts to tabs).
|
||||
Focus: '焦点',
|
||||
'Add to Focus': '添加到焦点',
|
||||
'Remove from Focus': '从焦点移除',
|
||||
'Focus actions': '焦点操作',
|
||||
'Could not save tab groups.': '无法保存标签分组。',
|
||||
'Tab groups changed elsewhere; part of your edit no longer applies.':
|
||||
'标签分组已在别处更改;你的部分编辑已不再适用。',
|
||||
@@ -139,6 +149,8 @@
|
||||
Drag: '拖动',
|
||||
"a tile's header": '窗格的标题栏',
|
||||
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||
"a tile's header out of the window": '窗格的标题栏到窗口之外',
|
||||
'Open the Tile in Its Own Window (Detach Tiles)': '在独立窗口中打开窗格(分离窗格)',
|
||||
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||
'Remove Focused Tile': '移除聚焦的窗格',
|
||||
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||
@@ -156,8 +168,9 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
|
||||
'Only image files are supported': '仅支持图像文件',
|
||||
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
|
||||
// view's drop (image-input.js) uses 'Only image or video files are supported'.
|
||||
'Unsupported file type': '不支持的文件类型',
|
||||
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
|
||||
'No active session': '没有活动会话',
|
||||
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
|
||||
@@ -166,6 +179,11 @@
|
||||
'Failed to restore terminal size': '恢复终端尺寸失败',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
// The same with Detach Tiles on (tile-grid.js _paintTileHandle), and a
|
||||
// pop-out window's title as a handle back onto the tiles.
|
||||
'Drag to move the tile, or out of the window to open it on its own': '拖动可移动窗格,拖出窗口可在独立窗口中打开',
|
||||
'Drag out of the window to open the tile on its own': '拖出窗口可在独立窗口中打开此窗格',
|
||||
"Drag onto a Codeman window's tiles to dock this session there": '拖到 {name} 窗口的窗格上,即可将此会话停靠到那里',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
'Resize tile rows': '调整窗格行高',
|
||||
Attach: '附加',
|
||||
@@ -214,6 +232,9 @@
|
||||
'Close window': '关闭窗口',
|
||||
'Session unavailable': '会话不可用',
|
||||
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
|
||||
// A pop-out whose session a dashboard took back, when it cannot close itself (app.js _showSoloReleased).
|
||||
'Session moved': '会话已移走',
|
||||
'This session is back in a Codeman window. This one can be closed.': '此会话已回到 {name} 窗口中。可以关闭此窗口。',
|
||||
|
||||
// Welcome / quick start / common actions
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
@@ -244,6 +265,50 @@
|
||||
'New or link a case…': '新建或关联案例…',
|
||||
'Case settings…': '案例设置…',
|
||||
'No cases match': '没有匹配的案例',
|
||||
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
|
||||
// phone case sheet and Close Session's worktree option). Worktree and branch names,
|
||||
// paths and git's own messages are user/git text and pass through untranslated.
|
||||
'Git worktrees…': 'Git 工作树…',
|
||||
'Git worktrees': 'Git 工作树',
|
||||
'Close git worktrees': '关闭 Git 工作树',
|
||||
Worktrees: '工作树',
|
||||
'Worktrees…': '工作树…',
|
||||
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
|
||||
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
|
||||
'Start sessions with': '启动会话所用',
|
||||
'New worktree': '新建工作树',
|
||||
Branch: '分支',
|
||||
'Start from': '起点',
|
||||
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
|
||||
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
|
||||
'Branch and start point': '分支和起点',
|
||||
"A branch, tag or commit. Default: the case's current commit (HEAD).":
|
||||
'分支、标签或提交。默认:案例当前提交(HEAD)。',
|
||||
'Create & start session': '创建并启动会话',
|
||||
'Loading worktrees…': '正在加载工作树…',
|
||||
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
|
||||
'detached HEAD': '游离 HEAD',
|
||||
'main checkout': '主检出',
|
||||
'not created by Codeman': '非 {name} 创建',
|
||||
locked: '已锁定',
|
||||
missing: '已丢失',
|
||||
Open: '打开',
|
||||
'Start a session in this worktree': '在此工作树中启动会话',
|
||||
'Remove…': '移除…',
|
||||
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
|
||||
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
|
||||
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
|
||||
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
|
||||
'Remove worktree': '移除工作树',
|
||||
'Kill & remove worktree': '终止并移除工作树',
|
||||
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
|
||||
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
|
||||
'Also delete its branch (git branch -d keeps a branch that is not merged)':
|
||||
'同时删除其分支(git branch -d 会保留未合并的分支)',
|
||||
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
|
||||
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
|
||||
'A launch is already in progress': '已有启动正在进行',
|
||||
'Codeman did not answer.': '{name} 没有响应。',
|
||||
'Add Case': '添加案例',
|
||||
'Open sessions': '打开会话',
|
||||
'Recent Sessions': '最近会话',
|
||||
@@ -405,6 +470,9 @@
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
'Detach Tiles': '分离窗格',
|
||||
"Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.":
|
||||
'将窗格的标题栏拖出浏览器,即可在独立窗口中打开它;拖到另一个 {name} 窗口的窗格上,即可将它移到那里。弹出窗口的标题也可以拖回窗格中。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
@@ -461,6 +529,9 @@
|
||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||
'Approvals Inbox': '审批收件箱',
|
||||
'Auto-name Sessions': '自动命名会话',
|
||||
'Spawned Tabs Follow Their Parent': '派生的标签跟随父会话',
|
||||
"A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.":
|
||||
'由另一个会话启动的会话会跟随父会话的标签进入其分组并随之移动,直到你手动移动它。关闭时:它出现在父会话旁边并停留在原处。',
|
||||
Approvals: '审批',
|
||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||
'No pending approvals': '没有待处理的审批',
|
||||
@@ -663,6 +734,8 @@
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
'Open a session to attach a file': '请先打开一个会话再添加附件',
|
||||
'Only image or video files are supported': '仅支持图片或视频文件',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
@@ -699,6 +772,16 @@
|
||||
'Session Color': '会话颜色',
|
||||
'Working Directory': '工作目录',
|
||||
'Set working directory': '设置工作目录',
|
||||
// Session Options > Session > Working folder (session-ui.js openSessionOptions).
|
||||
'Working folder': '工作目录',
|
||||
"Where this session's CLI runs, on the machine hosting Codeman.":
|
||||
'此会话的 CLI 运行所在的目录,位于运行{name}的机器上。',
|
||||
"Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine.":
|
||||
'此会话的 CLI 运行所在的目录,位于通过 SSH 连接的远程主机上,而不是本机。',
|
||||
"On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in.":
|
||||
'位于运行{name}的机器上,并挂载到此会话 CLI 运行所在的 Docker 容器中。',
|
||||
'Path copied': '路径已复制',
|
||||
'Copy failed': '复制失败',
|
||||
'Resume Conversation': '继续对话',
|
||||
'Close Session': '关闭会话',
|
||||
'Choose how to close': '选择关闭方式',
|
||||
@@ -1213,8 +1296,21 @@
|
||||
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
|
||||
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
|
||||
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
|
||||
// words pass through untranslated.
|
||||
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
|
||||
[
|
||||
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
|
||||
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
|
||||
],
|
||||
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
|
||||
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
|
||||
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
[/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`],
|
||||
[/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`],
|
||||
[
|
||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||
@@ -1280,9 +1376,8 @@
|
||||
// The same while tiles can move, with the drag hint on a second line.
|
||||
// Anchored on the hint, so a bare state word is safe here.
|
||||
[
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
|
||||
(_m, state, duration) =>
|
||||
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\n(Drag to move the tile|Drag to move the tile, or out of the window to open it on its own|Drag out of the window to open the tile on its own)$/,
|
||||
(_m, state, duration, hint) => `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN[hint]}`,
|
||||
],
|
||||
];
|
||||
for (const [pattern, replacement] of patterns) {
|
||||
|
||||
@@ -14,6 +14,69 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
initImageInput() {
|
||||
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
|
||||
// sits invisibly over the button, and the browser opens the picker itself.
|
||||
//
|
||||
// Nothing here may open the picker from script. While the keyboard is up,
|
||||
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
|
||||
// re-dispatches it as a scripted click, and iOS will not open a file picker
|
||||
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
|
||||
// milliseconds after pointerdown, no picker, no error, and no real click after
|
||||
// it either because the gesture was cancelled. A native tap on the input is not
|
||||
// a button tap, so the shim leaves it alone and the click stays trusted. It also
|
||||
// anchors the picker popover to the input's own frame (a `hidden` input has
|
||||
// none, which is why the composer's picker floated loose).
|
||||
//
|
||||
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
|
||||
// so the fixed toolbar never reflows out from under the finger mid-tap.
|
||||
//
|
||||
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
|
||||
// responder to present the picker, and on DEVICE it does not hand the keyboard
|
||||
// back when the picker closes, even though focus never left the textarea.
|
||||
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
|
||||
// not take a keyboard measurement from one.) A blur-then-focus on the close
|
||||
// of the picker, cancel and change alike, restores DOM focus and still leaves
|
||||
// the keyboard down, because iOS only raises it inside a user gesture. So the
|
||||
// user taps the prompt when ready to type, which is the keyboard policy
|
||||
// everywhere else in the app.
|
||||
//
|
||||
// Do not try to hide the keyboard's exit by freezing the page's keyboard
|
||||
// state across the picker. That was tried (holdKeyboardState, reverted) to
|
||||
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
|
||||
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
|
||||
// bar strand in the MIDDLE of the screen over a dead black band, which is far
|
||||
// worse than the reflow. The page must follow the keyboard out.
|
||||
const attachBtn = document.getElementById('attachBtnMobile');
|
||||
const attachInput = document.getElementById('attachFileInput');
|
||||
if (attachBtn && attachInput) {
|
||||
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
// The button is the ACCESSIBLE control (the input is aria-hidden and not
|
||||
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
|
||||
// the input. That click is trusted, so opening the picker from it is allowed.
|
||||
attachBtn.addEventListener('click', () => attachInput.click());
|
||||
attachInput.addEventListener('click', (event) => {
|
||||
if (!this.activeSessionId) {
|
||||
event.preventDefault(); // refuse before the picker opens, not after a pick
|
||||
this.showToast('Open a session to attach a file', 'error');
|
||||
return;
|
||||
}
|
||||
// Captured before the picker opens: the session can be deleted while it is
|
||||
// up, and the pick must not land on whatever replaced it.
|
||||
this._attachSessionId = this.activeSessionId;
|
||||
});
|
||||
attachInput.addEventListener('change', () => {
|
||||
const files = Array.from(attachInput.files || []);
|
||||
// Reset so picking the same file again still fires change.
|
||||
attachInput.value = '';
|
||||
// Captured when the picker opened (see the click handler above).
|
||||
const sessionId = this._attachSessionId;
|
||||
this._attachSessionId = null;
|
||||
if (files.length > 0) {
|
||||
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop handlers on terminal container
|
||||
const container = document.getElementById('terminalContainer');
|
||||
if (!container) return;
|
||||
@@ -38,15 +101,100 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
this.showToast('Only image files are supported', 'error');
|
||||
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
|
||||
if (mediaFiles.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(imageFiles);
|
||||
this._uploadAndInsertImages(mediaFiles);
|
||||
});
|
||||
},
|
||||
|
||||
/** Upload the toolbar Attach selection and put the paths on the prompt. The
|
||||
* Compose dialog is deliberately not involved (see initImageInput).
|
||||
*
|
||||
* With local echo on (every phone by default) the paths go into the OVERLAY as
|
||||
* pending text rather than to the PTY. That is what makes them part of "what the
|
||||
* user typed": they paint at the prompt, anything typed after them appends in
|
||||
* order, Enter flushes the whole line, and opening Compose adopts path and prose
|
||||
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
|
||||
* instead would strand the path on the terminal while Compose took only the prose.
|
||||
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
||||
* text goes straight out. The trailing space keeps the next word off the path. */
|
||||
async _attachFromToolbar(files, sessionId) {
|
||||
const media = files.filter((f) => this._promptAttachKind(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
||||
if (paths.length === 0) return;
|
||||
// Deleted while the upload ran: a write would go to a dead session, and the
|
||||
// flushed maps below would gain an entry whose cleanup has already run.
|
||||
if (this.sessions && !this.sessions.has(sessionId)) return;
|
||||
const text = paths.join(' ');
|
||||
|
||||
// The prompt composer, if it opened for this session while the upload ran, is
|
||||
// where the prompt now lives: it took the terminal's pending text with it, so
|
||||
// appending behind it would strand the path outside the text being composed.
|
||||
// _insertComposerText adds its own separator and fires the draft save.
|
||||
const openComposer = KeyboardAccessoryBar._composerOverlay;
|
||||
const composerTextarea =
|
||||
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
|
||||
? openComposer.querySelector('.prompt-composer-textarea')
|
||||
: null;
|
||||
if (composerTextarea) {
|
||||
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
|
||||
return;
|
||||
}
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
// The overlay holds the prompt only while local echo actually owns it. A
|
||||
// session handed back to plain PTY echo by a composer nav key (echo
|
||||
// passthrough) skips the whole overlay branch in onData, Enter included, so
|
||||
// text buffered there would sit on screen and never be submitted. Nor while
|
||||
// the tile grid owns the terminal area: the main terminal is parked and
|
||||
// hidden then, and a tile's Enter never reaches its overlay, so the path
|
||||
// goes to the PTY like any other background write.
|
||||
const overlayOwnsPrompt =
|
||||
stillActive &&
|
||||
!this._tilesOwnTerminal?.() &&
|
||||
this._localEchoEnabled &&
|
||||
this._localEchoOverlay &&
|
||||
!this._echoPassthroughSessions?.has(sessionId);
|
||||
if (overlayOwnsPrompt) {
|
||||
// Prompt text the overlay has not adopted yet (a tab completion, a restored
|
||||
// tab) only lands in its flushed half once detection runs, and appendText
|
||||
// runs it AFTER we would have measured. Detect first, or the separator is
|
||||
// computed against an empty string and the path fuses onto a real word.
|
||||
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
|
||||
this._localEchoOverlay.detectBufferText();
|
||||
}
|
||||
const flushed = this._localEchoOverlay.getFlushed().text || '';
|
||||
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
|
||||
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
|
||||
this._localEchoOverlay.appendText(gap + text + ' ');
|
||||
} else {
|
||||
// No buffer to measure, so lead with a separator unconditionally rather than
|
||||
// risk fusing onto whatever the CLI's own composer already holds.
|
||||
const out = ` ${text} `;
|
||||
this._sendInputAsync(sessionId, out);
|
||||
// A background session's prompt is tracked in the flushed maps, which is what
|
||||
// a later composer open adopts and erases. An untracked write leaves that
|
||||
// count short, so the erase would eat the tail of this path instead. Not for
|
||||
// a passthrough session: its cursor can sit mid-text, nothing tracks its
|
||||
// composer, and Enter there never clears the maps, so a record would offset
|
||||
// the overlay for the next prompt instead.
|
||||
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
|
||||
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
||||
if (!this._flushedTexts) this._flushedTexts = new Map();
|
||||
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
|
||||
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
@@ -82,15 +230,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images in clipboard items
|
||||
// Check for images or videos in clipboard items
|
||||
var imageFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +266,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
/** What a pick or a paste may upload: 'image', or 'video' for the camera
|
||||
* roll's clips (the server streams those to disk under their own cap), else
|
||||
* null. The one filter behind Attach, drop, the Compose and paste-dialog
|
||||
* pickers and the clipboard handlers. The MIME type decides; some Android
|
||||
* file managers hand over a File with none, so the name's extension is the
|
||||
* fallback, mirroring the server's own allowlists. */
|
||||
_promptAttachKind(file) {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
|
||||
return null;
|
||||
},
|
||||
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
@@ -136,6 +297,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return [];
|
||||
@@ -153,7 +315,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
@@ -197,11 +359,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
@@ -209,7 +371,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
||||
// A video takes the server's disk-streamed path and its larger cap; the flag
|
||||
// has to ride the URL because the size budget is fixed before the part is read.
|
||||
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
@@ -231,9 +396,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// decode/encode failure the original file is returned unchanged so the server
|
||||
// still gets a chance (and logs a precise diagnostic).
|
||||
async _normalizeImageForUpload(file) {
|
||||
if (file.type === 'image/gif') return file;
|
||||
// Only an image goes through the canvas; a video is uploaded as picked. With no
|
||||
// MIME (some Android file managers) the name's extension stands in, as it does in
|
||||
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
|
||||
const name = (file.name || '').toLowerCase();
|
||||
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
|
||||
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
|
||||
|
||||
const toPng = file.type === 'image/png';
|
||||
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
@@ -614,7 +614,11 @@
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>‹</button>
|
||||
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>›</button>
|
||||
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" 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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<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 file-preview-pill" id="filePreviewPageBtn" onclick="app.toggleFilePreviewPage()" title="Rendered page" aria-label="Rendered page" aria-pressed="true" hidden>Page</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>
|
||||
@@ -634,6 +638,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Grid: every previewable file of one folder (panels-ui.js openFolderGrid) -->
|
||||
<div class="folder-grid-overlay" id="folderGridOverlay">
|
||||
<div class="folder-grid-window">
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="folderGridTitle" data-i18n-skip></span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="folderGridUpBtn" onclick="app.folderGridUp()" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-grid-folders" id="folderGridFolders" data-i18n-skip></div>
|
||||
<div class="folder-grid-body" id="folderGridBody" data-i18n-skip></div>
|
||||
<div class="file-preview-footer" id="folderGridFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
|
||||
<div class="response-viewer" id="responseViewer">
|
||||
<div class="response-viewer-header">
|
||||
@@ -757,6 +777,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
<!-- Phone-only Attach (wired in image-input.js): the button opens its own file input,
|
||||
which sits invisibly over the button so iOS anchors the picker popover to it. The
|
||||
Compose dialog is deliberately not involved: it focuses its textarea, which raises
|
||||
the keyboard under the picker sheet. Sits BEFORE the mic so the two share order: 5
|
||||
and source order keeps Attach on the left. -->
|
||||
<span class="btn-attach-mobile-wrap">
|
||||
<button type="button" class="btn-attach-mobile" id="attachBtnMobile" title="Attach" aria-label="Attach">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="m21 15-5-5L5 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
|
||||
</span>
|
||||
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
|
||||
title="Voice input" aria-label="Start voice input" aria-pressed="false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -860,6 +895,7 @@
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
|
||||
<div><kbd>Drag</kbd> a tile's header out of the window</div><div>Open the Tile in Its Own Window (Detach Tiles)</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
||||
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
||||
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
|
||||
@@ -1286,6 +1322,22 @@
|
||||
<!-- Hidden wholesale for non-claude sessions and for a session with
|
||||
no live process (session-ui.js: openSessionOptions). -->
|
||||
<div id="sessionRespawnSection">
|
||||
<!-- Claude Code (v2.1.251+) reports the main conversation's prompt-cache
|
||||
state on the statusline; the exporter keeps it per session and this
|
||||
row shows it live (session-ui.js: updatePromptCacheStatus). -->
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Prompt cache</h4></div>
|
||||
<p class="set-group-hint">While the cached conversation is warm, a turn re-reads it at the cheap cached rate. Once it sits idle past the cache lifetime, the next turn re-processes the whole context at the cache-write rate.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Status</span>
|
||||
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Loop control</h4></div>
|
||||
<div class="set-group-body">
|
||||
@@ -1407,6 +1459,16 @@
|
||||
<input type="text" id="modalSessionName" class="set-input" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row set-row-block">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Working folder</span>
|
||||
<span class="set-row-desc" id="modalSessionPathDesc">Where this session's CLI runs, on the machine hosting Codeman.</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<code id="modalSessionPath" class="set-static mono" style="flex: 1; min-width: 0; word-break: break-all; user-select: all;"></code>
|
||||
<button type="button" class="btn-toolbar btn-sm" id="modalSessionPathCopy" onclick="app.copySessionPath()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row set-row-block">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Session color</span>
|
||||
@@ -1631,6 +1693,18 @@
|
||||
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
|
||||
<span class="close-option-desc">Terminate the session completely</span>
|
||||
</button>
|
||||
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
|
||||
app.js): kill it, then `git worktree remove` without --force. -->
|
||||
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
|
||||
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
|
||||
<span class="close-option-title">Kill & remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
|
||||
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
|
||||
</button>
|
||||
<label class="close-option-worktree-branch">
|
||||
<input type="checkbox" id="closeConfirmWorktreeBranch">
|
||||
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer-cancel">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
|
||||
@@ -1638,6 +1712,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
|
||||
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
|
||||
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
|
||||
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
|
||||
<div class="modal-content worktree-modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
|
||||
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
|
||||
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
|
||||
<div class="worktree-list" id="worktreeList"></div>
|
||||
<div class="form-row worktree-mode-row">
|
||||
<label for="worktreeMode">Start sessions with</label>
|
||||
<select id="worktreeMode" class="form-select"></select>
|
||||
</div>
|
||||
<fieldset class="worktree-new">
|
||||
<legend>New worktree</legend>
|
||||
<div class="form-row">
|
||||
<label for="worktreeName">Name</label>
|
||||
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
|
||||
<span class="form-hint">New branch worktree-<name> from the case's current commit, unless you pick another below.</span>
|
||||
</div>
|
||||
<details class="worktree-advanced">
|
||||
<summary>Branch and start point</summary>
|
||||
<div class="form-row">
|
||||
<label for="worktreeBranch">Branch</label>
|
||||
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="worktreeBase">Start from</label>
|
||||
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
|
||||
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create & start session</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Settings Modal -->
|
||||
<div class="modal" id="appSettingsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
|
||||
@@ -2233,6 +2353,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
|
||||
<span class="set-row-desc">Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTileDetach"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||
@@ -2247,6 +2374,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsSpawnedTabsFollowParentItem" data-search="spawned child session follow parent tab group lineage move">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawned Tabs Follow Their Parent <span class="set-tag">synced</span></span>
|
||||
<span class="set-row-desc">A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSpawnedTabsFollowParent"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
@@ -3587,6 +3721,11 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create New Case
|
||||
</button>
|
||||
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
|
||||
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
|
||||
<span aria-hidden="true">⑂</span>
|
||||
Worktrees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -636,12 +636,13 @@ function applyOneShotCtrl(data) {
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
|
||||
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
|
||||
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
|
||||
// an ACK, which would wedge the durable input queue. So the prompt budget is
|
||||
// that limit minus the two markers, derived here once so the refusal in
|
||||
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
|
||||
// The composer's Send, and the shell Paste dialog's, goes out as ONE
|
||||
// bracketed-paste frame on the WebSocket input path, and ws-routes.ts drops a
|
||||
// frame longer than MAX_INPUT_LENGTH (config/terminal-limits.ts: 64 KiB,
|
||||
// compared in UTF-16 code units) WITHOUT an ACK, which would wedge the durable
|
||||
// input queue. So the budget is that limit minus the two markers, derived here
|
||||
// once so the refusal in _sendBracketedPaste() and the toast that names the
|
||||
// maximum cannot drift.
|
||||
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
|
||||
const COMPOSER_PASTE_START = '\x1b[200~';
|
||||
const COMPOSER_PASTE_END = '\x1b[201~';
|
||||
@@ -664,7 +665,7 @@ const KeyboardAccessoryBar = {
|
||||
_composerDrafts: new Map(),
|
||||
_composerUploads: new Map(),
|
||||
_composerOverlay: null,
|
||||
// Longest prompt Send accepts: the input frame limit minus both markers.
|
||||
// Longest prompt or shell paste Send accepts: the input frame limit minus both markers.
|
||||
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
|
||||
@@ -1205,23 +1206,35 @@ const KeyboardAccessoryBar = {
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
|
||||
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
|
||||
* its DECSET 2004 mirror after terminal replay, even though the CLI still
|
||||
* expects bracketed input, so build the byte-identical sequence directly on
|
||||
* the durable session-bound path. Enter stays a separate delayed write
|
||||
* because Codex drops keys sharing a PTY read with a bracketed paste. */
|
||||
/** Deliver one complete prompt as an explicit bracketed paste
|
||||
* (_sendBracketedPaste()), then Enter. xterm loses its DECSET 2004 mirror
|
||||
* after terminal replay, even though the CLI still expects bracketed input,
|
||||
* so the frame is built directly rather than through terminal.paste().
|
||||
* Enter stays a separate delayed write because Codex drops keys sharing a
|
||||
* PTY read with a bracketed paste. */
|
||||
_sendComposedPrompt(sessionId, text) {
|
||||
if (!this._sendBracketedPaste(sessionId, text, 'Prompt')) return false;
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** One bracketed-paste write on the durable session-bound path, the same
|
||||
* bytes a terminal paste produces, so the receiving shell or CLI takes a
|
||||
* multi-line block as one edit. Never presses Enter: callers decide.
|
||||
* `label` names the payload in the too-long toast. */
|
||||
_sendBracketedPaste(sessionId, text, label) {
|
||||
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
|
||||
// the single-line input transport and is what terminal.paste() emitted.
|
||||
const pasteText = text.replace(/\r?\n/g, '\r');
|
||||
// Paste markers inside the text are dropped first: a clipboard carrying
|
||||
// `ESC[201~` would end the bracket early and run the rest without Enter.
|
||||
const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
|
||||
if (pasteText.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return false;
|
||||
}
|
||||
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1247,7 +1260,11 @@ const KeyboardAccessoryBar = {
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
// A parked draft and text typed (or attached) on the prompt since are two
|
||||
// separate pieces of prose; one space keeps the draft's last word off the
|
||||
// prompt's first, unless either side already supplies the gap.
|
||||
const needsGap = saved && pending && !/\s$/.test(saved) && !/^\s/.test(pending);
|
||||
const initial = needsGap ? `${saved} ${pending}` : saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
@@ -1269,7 +1286,7 @@ const KeyboardAccessoryBar = {
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1285,6 +1302,10 @@ const KeyboardAccessoryBar = {
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
// Disabled alongside Send while an upload runs: this button deletes the draft
|
||||
// and closes the overlay, and the upload would then land its paths in a FRESH
|
||||
// draft, which the next open would prepend to the handed-back text.
|
||||
const terminalButton = overlay.querySelector('.prompt-composer-terminal');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
@@ -1292,6 +1313,7 @@ const KeyboardAccessoryBar = {
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
terminalButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
@@ -1318,7 +1340,7 @@ const KeyboardAccessoryBar = {
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
@@ -1335,6 +1357,8 @@ const KeyboardAccessoryBar = {
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
|
||||
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
@@ -1366,9 +1390,8 @@ const KeyboardAccessoryBar = {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
.filter((file) => file && app._promptAttachKind(file));
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
@@ -1382,7 +1405,55 @@ const KeyboardAccessoryBar = {
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
// single-line draft can make the trip: the prompt transport is single-line
|
||||
// and strips embedded newlines, so a multi-line draft would arrive mangled
|
||||
// and instead stays parked as a draft (the dot on the Compose key shows it).
|
||||
const text = textarea.value;
|
||||
if (!text || text.includes('\n')) {
|
||||
close({ focusTerminal: true });
|
||||
return;
|
||||
}
|
||||
// Refuse an oversized draft BEFORE it is deleted, or the text is gone from
|
||||
// both surfaces. Same cap as Send: the overlay branch lays the whole text out
|
||||
// as DOM lines on every repaint, so the transport's larger paste cap is not
|
||||
// the bound that matters here.
|
||||
if (text.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return;
|
||||
}
|
||||
// Cleared, never kept: the text now lives on the prompt, and a surviving
|
||||
// draft would be prepended to it the next time the composer opens.
|
||||
this._composerDrafts.delete(sessionId);
|
||||
close({ focusTerminal: true, preserveDraft: false });
|
||||
if (
|
||||
app._localEchoEnabled &&
|
||||
app._localEchoOverlay &&
|
||||
app.activeSessionId === sessionId &&
|
||||
!app._tilesOwnTerminal?.() &&
|
||||
!app._echoPassthroughSessions?.has(sessionId)
|
||||
) {
|
||||
// Buffer echo: the prompt is the overlay, so the text goes there unsent.
|
||||
// Passthrough is excluded deliberately: such a session skips the overlay
|
||||
// branch on Enter, so text parked there would never be submitted. The tile
|
||||
// grid is excluded for the same reason _attachFromToolbar is: the main
|
||||
// terminal is parked and hidden then, and a tile's Enter cannot flush its
|
||||
// overlay, so the text goes to the PTY below instead of stranding there.
|
||||
app._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
// Write-through echo (codex) or no local echo at all: there is no buffer
|
||||
// to park text in, because the prompt the user sees IS the CLI's own
|
||||
// composer on the PTY. Checking for the overlay alone left this branch
|
||||
// doing nothing on codex, so the button looked dead there. That composer
|
||||
// may already hold text the open adopted nothing of, so lead with a
|
||||
// separator rather than fuse onto its last word (harmless when empty).
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
app._sendInputAsync(sessionId, ` ${text}`);
|
||||
}
|
||||
this._syncComposerDraftIndicator();
|
||||
});
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
@@ -1399,7 +1470,7 @@ const KeyboardAccessoryBar = {
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
* - Image (picker): the "Image" button opens a native file picker
|
||||
* (accept=image/* → camera / photo library / files), the most reliable
|
||||
* (accept=image/*,video/* → camera / photo library / files), the most reliable
|
||||
* way to attach a photo on mobile.
|
||||
* - Image (paste): if the browser exposes image blobs on the textarea's
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
@@ -1408,19 +1479,22 @@ const KeyboardAccessoryBar = {
|
||||
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
|
||||
pasteFromClipboard() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
// The shell this overlay was opened for: a tab switch while it is open must
|
||||
// not deliver the paste to whatever session is active at Send time.
|
||||
const sessionId = app.activeSessionId;
|
||||
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1429,22 +1503,30 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
// Only shell sessions reach this overlay (agent bars get Compose instead),
|
||||
// so the text lands at the prompt as one bracketed paste and is NOT run:
|
||||
// sent raw, every newline was an Enter and each line ran on its own as it
|
||||
// arrived. The user reviews the block and presses Enter themselves. A shell
|
||||
// that never enabled bracketed paste (dash, bash before 5.1) gets the
|
||||
// markers stripped by tmux, so there every line but the last still runs.
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
close();
|
||||
if (text) {
|
||||
app.sendInput(text);
|
||||
setTimeout(() => app.sendInput('\r'), 80);
|
||||
if (!text) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// A refused (too-long) paste keeps the dialog and its text for trimming.
|
||||
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
// Filter to images and videos, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
|
||||
// Upload to, and insert into, the shell this overlay was opened for.
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images, { sessionId });
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1458,10 +1540,8 @@ const KeyboardAccessoryBar = {
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -794,7 +794,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mobile-overview-row-wrap';
|
||||
wrap.appendChild(item);
|
||||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
|
||||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval, row));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -949,7 +949,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
|
||||
_buildMobileOverviewApprovalStrip(approval) {
|
||||
_buildMobileOverviewApprovalStrip(approval, row) {
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'mobile-overview-approval-strip';
|
||||
strip.setAttribute('data-i18n-skip', '');
|
||||
@@ -964,6 +964,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
strip.appendChild(btn);
|
||||
};
|
||||
// Alert-only (DeepSeek Harness / supervised hooks): open the session instead
|
||||
// of Approve/Deny that the answer route would refuse.
|
||||
if (approval.answerInTerminal) {
|
||||
addBtn('Answer in terminal', 'primary', () => {
|
||||
if (typeof this.selectSession === 'function') this.selectSession(approval.sessionId);
|
||||
else if (typeof this.openApprovalSession === 'function') this.openApprovalSession(approval.id);
|
||||
});
|
||||
return strip;
|
||||
}
|
||||
if (approval.options && approval.options.length) {
|
||||
for (const o of approval.options) {
|
||||
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
|
||||
|
||||
@@ -614,7 +614,9 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Mobile voice input button in toolbar — initially hidden via inline style,
|
||||
shown by VoiceInput._showButtons() clearing the inline display */
|
||||
.btn-voice-mobile {
|
||||
/* The Attach button shares the mic's box; its toolbar slot is on its wrapper below. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -626,15 +628,48 @@ html.mobile-init .file-browser-panel {
|
||||
color: #93c5fd;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
order: 5; /* Between Run Shell and settings gear */
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg {
|
||||
/* Toolbar slot for both, between Run Shell and the settings gear. Attach's
|
||||
WRAPPER takes it (the button inside is not a toolbar flex item), and at an
|
||||
equal order source order keeps Attach on the left of the mic. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile-wrap {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
/* Relative box for the invisible file input that sits over the button and TAKES
|
||||
THE TAPS (see initImageInput): a native tap on the input is the only thing that
|
||||
opens the picker while the keyboard is up, and iOS anchors the picker popover to
|
||||
the input's own frame. overflow: hidden is load-bearing: WebKit does not clip a
|
||||
file input, so its "Choose Files" content (~115px in this 26px box) spilled out
|
||||
invisibly over the mic and took its taps. Chromium clips it, so only iOS showed it. */
|
||||
.btn-attach-mobile-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-attach-mobile-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg,
|
||||
.btn-attach-mobile svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.btn-voice-mobile:active {
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile:active,
|
||||
/* The input above the button swallows the press, so the wrapper is what goes
|
||||
:active. Without this the only phone button with no press feedback is Attach. */
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: #2a3a5f;
|
||||
}
|
||||
|
||||
@@ -3245,13 +3280,22 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-attach-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Attach's press feedback comes from its wrapper (the file input over the button
|
||||
takes the press). Kept out of the :is() list above on purpose: a descendant
|
||||
selector there would raise that list's specificity for every button in it. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
|
||||
@@ -30,10 +30,11 @@ const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
||||
// would be dropped on the next save).
|
||||
const FILE_PREVIEW_PREF_KEYS = {
|
||||
mdRendered: 'codeman:filePreviewMdRendered',
|
||||
htmlRendered: 'codeman:filePreviewHtmlRendered',
|
||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
||||
wrap: 'codeman:filePreviewWrap',
|
||||
};
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -4037,6 +4038,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* unknown workingDir answers false, leaving the historical path untouched.
|
||||
*/
|
||||
_isExternalPreviewPath(filePath, sessionId) {
|
||||
// `~/…` (a terminal or response-viewer link): only the server knows the home
|
||||
// of the session's host, and only the attachment route expands it.
|
||||
if (typeof filePath === 'string' && filePath.startsWith('~/')) return true;
|
||||
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
|
||||
const workingDir = this.sessions.get(sessionId)?.workingDir;
|
||||
if (!workingDir) return false;
|
||||
@@ -4083,8 +4087,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
|
||||
* granted folder (folder grid): rendered from the folder's by-name routes, so
|
||||
* paging through 300 renders never touches the attachment registry.
|
||||
*/
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
|
||||
if (!sessionId || !filePath) return;
|
||||
const folderFile = options.folderFile || null;
|
||||
// Prev/next only while browsing a folder grid; a preview opened from anywhere
|
||||
// else ends that browsing.
|
||||
if (!folderFile && this.folderGrid) this.folderGrid.index = null;
|
||||
this._updateFolderPreviewNav(folderFile ? null : this._previewFolderPath(filePath, sessionId, attachmentId));
|
||||
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
const titleEl = this.$('filePreviewTitle');
|
||||
@@ -4093,6 +4108,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (!overlay || !bodyEl) return;
|
||||
|
||||
// Only the newest load may write: paging ←/→ through a folder starts the next
|
||||
// load while the previous one still awaits its fetch, and its late answer would
|
||||
// put the previous file's text under the next file's title.
|
||||
const load = (this._filePreviewLoad = (this._filePreviewLoad || 0) + 1);
|
||||
const stale = () => load !== this._filePreviewLoad;
|
||||
|
||||
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
||||
this._resetFilePreviewEdit();
|
||||
// Stop whatever the previous preview was playing. Overwriting innerHTML
|
||||
@@ -4106,6 +4127,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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.filePreviewPage = null;
|
||||
this.filePreviewPageRefused = false;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
|
||||
// Show overlay with loading state
|
||||
@@ -4116,14 +4139,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
|
||||
// HTML renders as a page: the server mints a sandboxed /html-view URL for the
|
||||
// file's directory (html-view-routes.ts), so relative CSS, scripts and images
|
||||
// resolve, and the page runs in an opaque origin that cannot reach Codeman.
|
||||
// The Page pill switches to the source view below (with its Edit button), and
|
||||
// a refused mint (remote case, broad folder, hidden directory) falls through
|
||||
// to that source view on its own.
|
||||
if (!attachmentId && rendersAsPage(filePath) && this._filePreviewPref('htmlRendered')) {
|
||||
const pageUrl = await this._mintHtmlViewUrl(filePath, sessionId);
|
||||
if (pageUrl) {
|
||||
footerEl.textContent = 'HTML';
|
||||
this.filePreviewPage = { filePath, sessionId };
|
||||
this.filePreviewDetachUrl = pageUrl;
|
||||
if (detachBtn) detachBtn.hidden = false;
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(pageUrl)}" title="${escapeHtml(filePath)}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"></iframe>`;
|
||||
this._updateFilePreviewToolbar('page');
|
||||
return;
|
||||
}
|
||||
// The pill would only retry a page the server just refused.
|
||||
this.filePreviewPageRefused = true;
|
||||
}
|
||||
|
||||
// Out-of-workspace path: mint an attachment id up front. Every branch below
|
||||
// talks to a workspace-confined route, so without this the image/PDF ones
|
||||
// render a broken frame and the text one reports a bare "File not found"
|
||||
// for a file that is sitting right there on disk.
|
||||
let externalError = '';
|
||||
let externalSize = 0;
|
||||
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) {
|
||||
if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) {
|
||||
const external = await this._registerExternalPreview(filePath, sessionId);
|
||||
if (stale()) return;
|
||||
attachmentId = external.attachmentId || null;
|
||||
externalError = external.error || '';
|
||||
externalSize = external.size || 0;
|
||||
@@ -4140,9 +4185,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (html/htm arrive as a download there by design — file-raw serves them
|
||||
// attachment-only so widening READ never widens RUN.)
|
||||
const officeDoc = ext === 'docx' || ext === 'pptx';
|
||||
const byIdBase = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`
|
||||
: folderFile
|
||||
? `/api/sessions/${sessionId}/folders/${encodeURIComponent(folderFile.folderId)}/files/${encodeURIComponent(folderFile.name)}`
|
||||
: null;
|
||||
this.filePreviewDetachUrl = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
byIdBase
|
||||
? `${byIdBase}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
@@ -4152,8 +4202,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
// A folder-grid file renders the same way, from its folder's by-name routes.
|
||||
if (byIdBase) {
|
||||
const base = CodemanBase.url(byIdBase);
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||
// (src/attachment-registry.ts, the single source); the frontend cannot import
|
||||
@@ -4163,6 +4214,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Size when we just registered the file ourselves, so a path opened from a
|
||||
// link reads like a workspace preview instead of a bare "PNG". History
|
||||
// cards arrive with an id and no size and keep the short form.
|
||||
if (folderFile && !externalSize) externalSize = options.size || 0;
|
||||
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||
@@ -4190,6 +4242,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
|
||||
if (!res.ok) throw new Error('Failed to load attachment');
|
||||
const text = await res.text();
|
||||
if (stale()) return;
|
||||
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
// Markdown keeps every line the Range read returned: the byte bound is
|
||||
@@ -4201,13 +4254,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
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.filePreviewText = { ext, sessionId, filePath, attachmentId: attachmentId || folderFile?.folderId };
|
||||
this._renderFilePreviewText();
|
||||
if (clippedByLines || clippedByBytes) {
|
||||
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
|
||||
footerEl.textContent = `${footerEl.textContent} (${note})`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -4240,11 +4294,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
if (!res.ok) throw new Error('Failed to load image');
|
||||
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
|
||||
const svgText = await res.text();
|
||||
if (stale()) return;
|
||||
const blobUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
|
||||
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
|
||||
const img = bodyEl.querySelector('img');
|
||||
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
return;
|
||||
@@ -4260,6 +4317,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
|
||||
const result = await res.json();
|
||||
if (stale()) return;
|
||||
if (!result.success) throw new Error(result.error || 'Failed to load file');
|
||||
|
||||
const data = result.data;
|
||||
@@ -4299,14 +4357,266 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
console.error('Failed to preview file:', err);
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Folder grid =====
|
||||
|
||||
/**
|
||||
* Show every previewable file of a folder as a grid (absolute or `~/` path on the
|
||||
* session's host). The server grants the folder an id (`POST …/folders`), and the
|
||||
* grid and the previews it opens fetch files by `folderId + name` only.
|
||||
*
|
||||
* Opened from a clicked folder path: when the server finds a FILE there after all,
|
||||
* it falls back to the file preview, i.e. what a click did before folders were links.
|
||||
*/
|
||||
async openFolderGrid(folderPath, sessionId = this.activeSessionId) {
|
||||
if (!sessionId || !folderPath) return;
|
||||
const overlay = this.$('folderGridOverlay');
|
||||
const body = this.$('folderGridBody');
|
||||
if (!overlay || !body) return;
|
||||
this._bindFolderGridKeys();
|
||||
// Two quick opens (a chip, then its parent): only the newer one may render.
|
||||
const load = (this._folderGridLoad = (this._folderGridLoad || 0) + 1);
|
||||
let result = null;
|
||||
let status = 0;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/folders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: folderPath }),
|
||||
});
|
||||
status = res.status;
|
||||
result = await res.json().catch(() => null);
|
||||
} catch (err) {
|
||||
if (load !== this._folderGridLoad) return;
|
||||
this.showToast(`Cannot open folder: ${err.message || err}`, 'error');
|
||||
return;
|
||||
}
|
||||
if (load !== this._folderGridLoad) return;
|
||||
if (!result?.success || !result.data) {
|
||||
const reason = result?.error || `HTTP ${status}`;
|
||||
if (status === 400 && /not a folder/i.test(reason)) {
|
||||
this.openFilePreview(folderPath, sessionId);
|
||||
return;
|
||||
}
|
||||
this.showToast(`Cannot open folder ${folderPath}: ${reason}`, 'error');
|
||||
return;
|
||||
}
|
||||
this.folderGrid = { sessionId, ...result.data, index: null };
|
||||
this._renderFolderGrid();
|
||||
overlay.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
},
|
||||
|
||||
closeFolderGrid() {
|
||||
// A listing still on its way must not reopen the grid that was just closed.
|
||||
this._folderGridLoad = (this._folderGridLoad || 0) + 1;
|
||||
this.$('folderGridOverlay')?.classList.remove('visible');
|
||||
const body = this.$('folderGridBody');
|
||||
// Drop the tiles: lazy <img>s still loading would keep fetching otherwise.
|
||||
if (body) body.innerHTML = '';
|
||||
this.folderGrid = null;
|
||||
this._updateFolderPreviewNav(null);
|
||||
},
|
||||
|
||||
folderGridUp() {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || grid.path === '/') return;
|
||||
this.openFolderGrid(grid.path.slice(0, grid.path.lastIndexOf('/')) || '/', grid.sessionId);
|
||||
},
|
||||
|
||||
_renderFolderGrid() {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid) return;
|
||||
const title = this.$('folderGridTitle');
|
||||
const foldersEl = this.$('folderGridFolders');
|
||||
const body = this.$('folderGridBody');
|
||||
const footer = this.$('folderGridFooter');
|
||||
title.textContent = grid.path;
|
||||
title.title = grid.path;
|
||||
const upBtn = this.$('folderGridUpBtn');
|
||||
if (upBtn) upBtn.disabled = grid.path === '/';
|
||||
|
||||
foldersEl.innerHTML = '';
|
||||
for (const name of grid.folders) {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'folder-grid-chip';
|
||||
chip.textContent = `${name}/`;
|
||||
chip.onclick = () => this.openFolderGrid(`${grid.path === '/' ? '' : grid.path}/${name}`, grid.sessionId);
|
||||
foldersEl.appendChild(chip);
|
||||
}
|
||||
|
||||
body.innerHTML = '';
|
||||
if (grid.files.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'folder-grid-empty';
|
||||
empty.textContent = 'No previewable files in this folder.';
|
||||
body.appendChild(empty);
|
||||
}
|
||||
grid.files.forEach((file, index) => {
|
||||
const tile = document.createElement('button');
|
||||
tile.type = 'button';
|
||||
tile.className = 'folder-grid-tile';
|
||||
tile.title = `${file.name} • ${this.formatFileSize(file.size)}`;
|
||||
tile.dataset.index = String(index);
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'folder-grid-thumb';
|
||||
const label = file.extension.toUpperCase();
|
||||
const route = this._folderFileUrl(grid, file.name, '');
|
||||
// Images load themselves (lazily — a folder can hold hundreds of renders);
|
||||
// PDF/Office show their first page where the server can render one (local
|
||||
// folders), everything else a type label.
|
||||
const src =
|
||||
file.attachmentType === 'image'
|
||||
? `${route}/raw`
|
||||
: ['pdf', 'docx', 'pptx'].includes(file.extension)
|
||||
? `${route}/thumbnail`
|
||||
: null;
|
||||
if (src) {
|
||||
const img = document.createElement('img');
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.alt = file.name;
|
||||
img.src = src;
|
||||
img.onerror = () => {
|
||||
thumb.textContent = label;
|
||||
};
|
||||
thumb.appendChild(img);
|
||||
} else {
|
||||
thumb.textContent = label;
|
||||
}
|
||||
const name = document.createElement('span');
|
||||
name.className = 'folder-grid-name';
|
||||
name.textContent = file.name;
|
||||
tile.append(thumb, name);
|
||||
tile.onclick = () => this._openFolderGridFile(index);
|
||||
body.appendChild(tile);
|
||||
});
|
||||
|
||||
const count = `${grid.files.length} file${grid.files.length === 1 ? '' : 's'}`;
|
||||
const subfolders = grid.folders.length ? ` • ${grid.folders.length} folder${grid.folders.length === 1 ? '' : 's'}` : '';
|
||||
footer.textContent = `${count}${subfolders}${grid.truncated ? ' (folder too large — showing the first entries)' : ''}`;
|
||||
},
|
||||
|
||||
_folderFileUrl(grid, name, suffix) {
|
||||
return CodemanBase.url(
|
||||
`/api/sessions/${grid.sessionId}/folders/${encodeURIComponent(grid.folderId)}/files/${encodeURIComponent(name)}${suffix}`
|
||||
);
|
||||
},
|
||||
|
||||
_openFolderGridFile(index) {
|
||||
const grid = this.folderGrid;
|
||||
const file = grid?.files[index];
|
||||
if (!file) return;
|
||||
const body = this.$('folderGridBody');
|
||||
body?.querySelectorAll('.folder-grid-tile.current').forEach((t) => t.classList.remove('current'));
|
||||
const tile = body?.querySelector(`.folder-grid-tile[data-index="${index}"]`);
|
||||
tile?.classList.add('current');
|
||||
tile?.scrollIntoView({ block: 'nearest' });
|
||||
this.openFilePreview(`${grid.path === '/' ? '' : grid.path}/${file.name}`, grid.sessionId, null, {
|
||||
folderFile: { folderId: grid.folderId, name: file.name },
|
||||
size: file.size,
|
||||
});
|
||||
// After openFilePreview: it resets the index for previews from elsewhere.
|
||||
grid.index = index;
|
||||
grid.lastIndex = index;
|
||||
this._updateFolderPreviewNav(null);
|
||||
},
|
||||
|
||||
/** Page through the open folder from its preview (‹ › buttons, ←/→). */
|
||||
stepFolderPreview(delta) {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || grid.index === null || grid.files.length === 0) return;
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
const next = (grid.index + delta + grid.files.length) % grid.files.length;
|
||||
this._openFolderGridFile(next);
|
||||
},
|
||||
|
||||
/** "Show folder" in the preview: the grid of the previewed file's folder. */
|
||||
openFolderOfPreview() {
|
||||
const target = this.filePreviewFolderTarget;
|
||||
if (!target) return;
|
||||
this.closeFilePreview();
|
||||
this.openFolderGrid(target.dir, target.sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* The folder a previewed file lives in, or null when that is unknown — an
|
||||
* attachment card carries only a bare file name.
|
||||
*/
|
||||
_previewFolderPath(filePath, sessionId, attachmentId) {
|
||||
if (typeof filePath !== 'string' || (attachmentId && !filePath.includes('/'))) return null;
|
||||
const workingDir = this.sessions?.get?.(sessionId)?.workingDir;
|
||||
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(filePath, workingDir) : filePath;
|
||||
if (!resolved.startsWith('/') && !resolved.startsWith('~/')) return null;
|
||||
const cut = resolved.lastIndexOf('/');
|
||||
return { dir: resolved.slice(0, cut) || '/', sessionId };
|
||||
},
|
||||
|
||||
/** Show prev/next while browsing a folder grid, else "Show folder" when the file's folder is known. */
|
||||
_updateFolderPreviewNav(folderTarget) {
|
||||
const browsing = !!this.folderGrid && this.folderGrid.index !== null && this.folderGrid.files.length > 1;
|
||||
const prev = this.$('filePreviewPrevBtn');
|
||||
const next = this.$('filePreviewNextBtn');
|
||||
if (prev) prev.hidden = !browsing;
|
||||
if (next) next.hidden = !browsing;
|
||||
if (folderTarget !== undefined) this.filePreviewFolderTarget = folderTarget;
|
||||
const inGrid = !!this.folderGrid && this.folderGrid.index !== null;
|
||||
const folderBtn = this.$('filePreviewFolderBtn');
|
||||
if (folderBtn) folderBtn.hidden = inGrid || !this.filePreviewFolderTarget;
|
||||
},
|
||||
|
||||
/**
|
||||
* ←/→ page the folder preview, Escape steps back (preview → grid → closed).
|
||||
* Capture phase + stopPropagation: the terminal keeps keyboard focus under the
|
||||
* overlays, and an arrow key that reached xterm would be typed into the session.
|
||||
*/
|
||||
_bindFolderGridKeys() {
|
||||
if (this._folderGridKeysBound) return;
|
||||
this._folderGridKeysBound = true;
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(ev) => {
|
||||
const grid = this.folderGrid;
|
||||
if (!grid || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||
const previewOpen = this.$('filePreviewOverlay')?.classList.contains('visible');
|
||||
const gridOpen = this.$('folderGridOverlay')?.classList.contains('visible');
|
||||
if (!gridOpen) return;
|
||||
const t = ev.target;
|
||||
const typing =
|
||||
t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')));
|
||||
if (typing || this.filePreviewEdit) return;
|
||||
let handled = true;
|
||||
if (previewOpen && grid.index !== null && ev.key === 'ArrowLeft') this.stepFolderPreview(-1);
|
||||
else if (previewOpen && grid.index !== null && ev.key === 'ArrowRight') this.stepFolderPreview(1);
|
||||
// Grid alone: an arrow opens the last viewed tile (or the first), so it
|
||||
// pages the folder instead of landing in the terminal behind the grid.
|
||||
else if (!previewOpen && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') && grid.files.length)
|
||||
this._openFolderGridFile(grid.lastIndex ?? 0);
|
||||
else if (ev.key === 'Escape') {
|
||||
if (previewOpen) this.closeFilePreview();
|
||||
else this.closeFolderGrid();
|
||||
} else handled = false;
|
||||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
closeFilePreview() {
|
||||
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||
this._resetFilePreviewEdit();
|
||||
// Back on the grid (if one is open underneath): its current tile stays marked.
|
||||
if (this.folderGrid) this.folderGrid.index = null;
|
||||
this._updateFolderPreviewNav(null);
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
@@ -4571,8 +4881,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* either view, Page for an HTML file in either view (`'page'` = rendered),
|
||||
* Lines/Wrap for the plain-text view only; `'none'` (loading, image, media,
|
||||
* PDF, edit mode) hides them all.
|
||||
*/
|
||||
_updateFilePreviewToolbar(view) {
|
||||
const set = (id, shown, pressed) => {
|
||||
@@ -4583,10 +4894,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
};
|
||||
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
|
||||
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
|
||||
// Page: shown on a rendered HTML page, and on its source view when that view
|
||||
// can be re-rendered (a bare attachment-card name has no path to mint from).
|
||||
const text = this.filePreviewText;
|
||||
const htmlSource =
|
||||
view === 'text' &&
|
||||
!this.filePreviewPageRefused &&
|
||||
rendersAsPage(text?.filePath) &&
|
||||
(!text.attachmentId || text.filePath.startsWith('/'));
|
||||
set('filePreviewPageBtn', view === 'page' || htmlSource, view === 'page');
|
||||
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
|
||||
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
|
||||
},
|
||||
|
||||
/** Mint the sandboxed page URL for an HTML file; null when the server refuses. */
|
||||
async _mintHtmlViewUrl(filePath, sessionId) {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/html-view`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath }),
|
||||
});
|
||||
const result = await res.json().catch(() => ({}));
|
||||
return res.ok && result.success ? CodemanBase.url(result.data.url) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Page pill: flip between the rendered page and the source view of an HTML file. */
|
||||
toggleFilePreviewPage() {
|
||||
const target = this.filePreviewPage || this.filePreviewText;
|
||||
if (!target) return;
|
||||
this._setFilePreviewPref('htmlRendered', !this._filePreviewPref('htmlRendered'));
|
||||
return this.openFilePreview(target.filePath, target.sessionId);
|
||||
},
|
||||
|
||||
toggleFilePreviewMd() {
|
||||
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
|
||||
this._renderFilePreviewText();
|
||||
|
||||
@@ -128,14 +128,37 @@ const CASE_PICKER_REFRESH_MS = 5000;
|
||||
/**
|
||||
* Action rows at the bottom of the toolbar case picker. They replaced the "+"
|
||||
* and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two
|
||||
* actions, one click away, without two extra controls in the toolbar. The arrow
|
||||
* keys reach them after the last case; Enter or a click runs `run`.
|
||||
* actions, one click away, without two extra controls in the toolbar, and the git
|
||||
* worktrees of the selected case joined them. The arrow keys reach them after the
|
||||
* last case; Enter or a click runs `run`.
|
||||
*/
|
||||
const CASE_PICKER_ACTIONS = [
|
||||
{ id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() },
|
||||
{ id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() },
|
||||
// The selected case's git worktrees (openWorktreeModal below): a row here rather than a
|
||||
// toolbar button, for the same reason the two above replaced theirs.
|
||||
{ id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() },
|
||||
];
|
||||
|
||||
/** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */
|
||||
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
/**
|
||||
* The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is
|
||||
* exactly `<case path>/.claude/worktrees/<name>` of a local case in `cases`, else null. Lexical
|
||||
* on purpose (the browser cannot resolve links): the server re-checks every removal against the
|
||||
* case's own repository, so a stale or wrong match can only be refused, never act elsewhere.
|
||||
*/
|
||||
function managedWorktreeOfDir(workingDir, cases) {
|
||||
if (typeof workingDir !== 'string') return null;
|
||||
const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir);
|
||||
if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null;
|
||||
const owner = (cases || []).find(
|
||||
(c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker'
|
||||
);
|
||||
return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null;
|
||||
}
|
||||
|
||||
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)]);
|
||||
|
||||
@@ -621,9 +644,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** Ensure a newly-created session is visible without waiting for the SSE event.
|
||||
* The POST response and session:created can arrive in either order, so the
|
||||
* normal idempotent SSE handler remains the single state-upsert path. */
|
||||
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
|
||||
* normal idempotent SSE handler remains the single state-upsert path.
|
||||
* `tabLayout` is the layout a grouped creation returns (see run()'s
|
||||
* `tabGroupId`): adopting it first draws the new tab inside its group. */
|
||||
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot, tabLayout) {
|
||||
if (!sessionId) return;
|
||||
if (tabLayout) this._applyTabLayout?.(tabLayout);
|
||||
|
||||
let session = sessionSnapshot;
|
||||
if (!session && !this.sessions?.has(sessionId)) {
|
||||
@@ -643,8 +669,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderSessionTabsImmediate?.();
|
||||
},
|
||||
|
||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
|
||||
async run() {
|
||||
/**
|
||||
* Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity).
|
||||
* `options` comes from the tab group menu's "New session": `count` overrides
|
||||
* the instance steppers for this launch only, `tabGroupId` rides every create
|
||||
* request so the server places the session in that group.
|
||||
*/
|
||||
async run(options = {}) {
|
||||
if (this._runInFlight) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
@@ -659,12 +690,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
return await this.runShell(options);
|
||||
}
|
||||
if (mode === 'claude' || !isExternalCliRunMode(mode)) {
|
||||
return await this.runClaude();
|
||||
return await this.runClaude(options);
|
||||
}
|
||||
return await this._runCliMode(mode);
|
||||
return await this._runCliMode(mode, options);
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
@@ -1108,7 +1139,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
*
|
||||
* Routes through run() itself, via a temporary `_runMode` swap, rather than a
|
||||
* parallel dispatch table: that is what gives this the same in-flight lock
|
||||
* every other Run click gets (CLAUDE.md, Run launch synchronization — the lock
|
||||
* every other Run click gets (src/web/CLAUDE.md, Run launch synchronization — the lock
|
||||
* exists so a double click cannot create duplicate sessions with the same
|
||||
* `w<n>-<case>` name, and it guards the OTHER direction too: without it, the
|
||||
* main Run button could start a second concurrent launch while this one was
|
||||
@@ -1910,12 +1941,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = this._readTabCount();
|
||||
async runClaude(options = {}) {
|
||||
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
|
||||
// that git worktree of the case and its tab is named after the worktree.
|
||||
const wt = options.worktree || null;
|
||||
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
|
||||
const nameBase = wt ? wt.name : caseName;
|
||||
const tabCount = this._readTabCount(options.count);
|
||||
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||
`Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
|
||||
);
|
||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||
@@ -1924,25 +1960,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Get case path first
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
let caseData = caseLookup?.data ?? {};
|
||||
let caseData;
|
||||
if (wt) {
|
||||
// A worktree is local by construction (the server refuses one for a remote or Docker
|
||||
// case) and its folder came from the worktree list: no case lookup, never a scaffold.
|
||||
caseData = { path: wt.path, location: 'local' };
|
||||
} else {
|
||||
// Get case path first
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
caseData = caseLookup?.data ?? {};
|
||||
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
@@ -1972,6 +2015,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const quickStartBody = JSON.stringify({
|
||||
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
|
||||
...group,
|
||||
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
|
||||
});
|
||||
const doQuickStart = async () => {
|
||||
@@ -2000,7 +2044,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
|
||||
@@ -2014,8 +2058,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName);
|
||||
// Find the highest existing w-number for THIS case (or worktree) to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(nameBase);
|
||||
|
||||
// Get global Ralph tracker setting
|
||||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||||
@@ -2023,7 +2067,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Create all sessions in parallel for speed
|
||||
const sessionNames = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
sessionNames.push(`w${startNumber + i}-${caseName}`);
|
||||
sessionNames.push(`w${startNumber + i}-${nameBase}`);
|
||||
}
|
||||
|
||||
// Build env overrides from global + case settings (case overrides global)
|
||||
@@ -2046,6 +2090,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workingDir, name,
|
||||
...group,
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(advisorModel ? { advisorModel } : {}),
|
||||
@@ -2059,7 +2104,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
firstSessionId = sessionIds[0];
|
||||
@@ -2128,40 +2173,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runShell() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
async runShell(options = {}) {
|
||||
// `options.worktree`: see runClaude.
|
||||
const wt = options.worktree || null;
|
||||
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
|
||||
const nameBase = wt ? wt.name : caseName;
|
||||
// Run Shell reads the toolbar's one instance stepper, like every other run*();
|
||||
// its own second `− 1 +` group (#shellCount) was removed (#428).
|
||||
const shellCount = this._readTabCount();
|
||||
const shellCount = this._readTabCount(options.count);
|
||||
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||
`Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`,
|
||||
'1;33'
|
||||
);
|
||||
|
||||
try {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
let caseData = caseLookup?.data ?? {};
|
||||
let caseData;
|
||||
if (wt) {
|
||||
// Local by construction, folder from the worktree list (see runClaude).
|
||||
caseData = { path: wt.path, location: 'local' };
|
||||
} else {
|
||||
// Get the case path
|
||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||
const caseLookup = await caseRes.json();
|
||||
caseData = caseLookup?.data ?? {};
|
||||
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
// Create the case only when the server says it does not exist. Any other
|
||||
// failure (a linked folder on a mount that is not answering) must not
|
||||
// scaffold a same-name local case that would then shadow the real one.
|
||||
if (!caseData.path) {
|
||||
if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed');
|
||||
const createCaseRes = await fetch('/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName, description: '' })
|
||||
});
|
||||
const createCaseData = await createCaseRes.json();
|
||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||
// API returns { success, data: { case: { name, path } } }
|
||||
caseData = createCaseData.data.case;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCase = (this.cases || []).find(c => c.name === caseName);
|
||||
const selectedCase = wt ? null : (this.cases || []).find(c => c.name === caseName);
|
||||
const isRemoteCase =
|
||||
caseData.location === 'remote' ||
|
||||
caseData.location === 'docker' ||
|
||||
@@ -2178,11 +2233,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
|
||||
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}`, ...group })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
if (remoteIds[0]) {
|
||||
@@ -2194,13 +2249,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
|
||||
// Find the highest existing s-number for THIS case (or worktree) to avoid duplicates
|
||||
const startNumber = this._nextCaseSessionStartNumber(nameBase, 's');
|
||||
|
||||
// Create all shell sessions in parallel
|
||||
const sessionNames = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
sessionNames.push(`s${startNumber + i}-${caseName}`);
|
||||
sessionNames.push(`s${startNumber + i}-${nameBase}`);
|
||||
}
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
@@ -2208,7 +2263,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
|
||||
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name, ...group })
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
@@ -2216,7 +2271,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
|
||||
@@ -2257,8 +2312,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* their try block, to put the count in the opening banner: `#tabCount` ships
|
||||
* unconditionally today, but a throw here would escape the launch-error path.
|
||||
*/
|
||||
_readTabCount() {
|
||||
return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1));
|
||||
_readTabCount(override) {
|
||||
return Math.min(20, Math.max(1, override ?? (parseInt(document.getElementById('tabCount')?.value) || 1)));
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2291,7 +2346,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
if (!data.success) throw new Error(data.error || `Failed to start ${label}`);
|
||||
customModelAnswered = true;
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
|
||||
if (!firstSessionId) firstSessionId = data.data.sessionId;
|
||||
}
|
||||
if (tabCount > 1) {
|
||||
@@ -2309,7 +2364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* menu call them directly by name (`app.runOpenCode()` etc.), and several
|
||||
* tests assert on that name directly too.
|
||||
*/
|
||||
async _runCliMode(mode) {
|
||||
async _runCliMode(mode, options = {}) {
|
||||
const catalogEntry = registryCliById(mode);
|
||||
const entry = RUN_MODE_LAUNCH[mode] ||
|
||||
(catalogEntry && {
|
||||
@@ -2319,16 +2374,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
buildConfig: () => null,
|
||||
});
|
||||
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
|
||||
// session starts through the same path as any other, only in the worktree's folder.
|
||||
const wt = options.worktree || null;
|
||||
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||
// probe and the local-only config/env below don't apply (quick-start
|
||||
// rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const tabCount = this._readTabCount(options.count);
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`
|
||||
);
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
@@ -2357,13 +2415,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// into `claude --settings`); none of these CLIs has an /effort. Each of
|
||||
// the eight bodies this launcher replaced carried that rule as a comment.
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
// Only names the tabs (`w<n>-<name>`): a worktree session is named after the worktree.
|
||||
wt ? wt.name : caseName,
|
||||
tabCount,
|
||||
entry.label,
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode,
|
||||
sessionName,
|
||||
...(wt ? { worktree: wt.name } : {}),
|
||||
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
|
||||
...(isRemote ? {} : {
|
||||
...(entry.buildConfig(globalSettings) || {}),
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
@@ -2473,6 +2534,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (tab) tab.classList.toggle('tab-show-detach', !!on);
|
||||
},
|
||||
|
||||
/** Copy the open session's working folder (Session Options > Session > Working folder). */
|
||||
async copySessionPath() {
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (!session?.workingDir) return;
|
||||
const ok = await this._copyText(session.workingDir);
|
||||
this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error');
|
||||
},
|
||||
|
||||
openSessionOptions(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -2534,6 +2603,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Populate auto-resume on usage limit (token pause control)
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -2552,6 +2622,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
|
||||
}
|
||||
|
||||
// Working folder: the path the CLI was launched in on the Codeman host
|
||||
const _pathEl = document.getElementById('modalSessionPath');
|
||||
_pathEl.textContent = session.workingDir || '';
|
||||
_pathEl.title = session.workingDir || '';
|
||||
document.getElementById('modalSessionPathCopy').disabled = !session.workingDir;
|
||||
// A remote session's workingDir is the REMOTE path; a docker session's is the
|
||||
// host bind-mount source the container sees.
|
||||
document.getElementById('modalSessionPathDesc').textContent = session.remote
|
||||
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
|
||||
: session.docker
|
||||
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
|
||||
: "Where this session's CLI runs, on the machine hosting Codeman.";
|
||||
|
||||
// Initialize color picker with current session color
|
||||
const currentColor = session.color || 'default';
|
||||
const colorPicker = document.getElementById('sessionColorPicker');
|
||||
@@ -2784,6 +2867,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Session Options → Respawn → "Prompt cache": the main conversation's cache state as
|
||||
* Claude Code reports it on the statusline (`session.promptCache`, v2.1.251+), worded by
|
||||
* the pure `CodemanPromptCache.format` (constants.js). Refreshed on open and on the
|
||||
* `session:promptCache` SSE event while the modal shows that session.
|
||||
*/
|
||||
updatePromptCacheStatus(sessionId) {
|
||||
const el = document.getElementById('promptCacheStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter);
|
||||
},
|
||||
|
||||
_onSessionPromptCache(data) {
|
||||
const { sessionId, ...promptCache } = data;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.promptCache = promptCache;
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
@@ -4951,6 +5055,404 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Remove animation class after it plays
|
||||
setTimeout(() => modal.classList.remove('from-mobile'), 300);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Git worktrees of a case (src/git-worktrees.ts, routes/worktree-routes.ts)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// One dialog per case: its worktrees (Open, Remove), and a form for a new one that starts a
|
||||
// session in it at once. Sessions start through run() with `options.worktree`, so every run
|
||||
// mode keeps its own launch path (POST /api/sessions for claude and shell, quick-start with
|
||||
// `worktree` for the rest). Everything git or the user named (worktree and branch names, paths,
|
||||
// git's own refusals) renders through textContent and carries data-i18n-skip.
|
||||
|
||||
/** Open the worktree dialog for `caseName` (default: the case selected in the toolbar). */
|
||||
openWorktreeModal(caseName) {
|
||||
const modal = document.getElementById('worktreeModal');
|
||||
const name = caseName || document.getElementById('quickStartCase')?.value || '';
|
||||
if (!modal || !name) return;
|
||||
this._worktreeCase = name;
|
||||
this._worktreeData = null;
|
||||
this._worktreeRemoveConfirm = null;
|
||||
this._worktreeRemoveError = null;
|
||||
const caseLabel = document.getElementById('worktreeModalCase');
|
||||
if (caseLabel) caseLabel.textContent = name;
|
||||
for (const id of ['worktreeName', 'worktreeBranch', 'worktreeBase']) {
|
||||
const input = document.getElementById(id);
|
||||
if (input) input.value = '';
|
||||
}
|
||||
this._setWorktreeFormError('');
|
||||
this._fillWorktreeModeSelect();
|
||||
this._renderWorktreeList();
|
||||
modal.classList.add('active');
|
||||
void this._loadWorktrees();
|
||||
// Not on a touch device: focusing the field raises the on-screen keyboard over the list.
|
||||
const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.();
|
||||
if (!touch) setTimeout(() => document.getElementById('worktreeName')?.focus?.(), 50);
|
||||
},
|
||||
|
||||
/** The phone case sheet's Worktrees button: the selected case's worktrees. */
|
||||
showWorktreesFromMobile() {
|
||||
this.closeMobileCasePicker();
|
||||
this.openWorktreeModal();
|
||||
},
|
||||
|
||||
closeWorktreeModal() {
|
||||
const modal = document.getElementById('worktreeModal');
|
||||
// Called on every Escape (app.js): a no-op unless the dialog is open.
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
modal.classList.remove('active');
|
||||
this._worktreeCase = null;
|
||||
this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1;
|
||||
},
|
||||
|
||||
_worktreesUrl(caseName, suffix = '') {
|
||||
return `/api/cases/${encodeURIComponent(caseName)}/worktrees${suffix}`;
|
||||
},
|
||||
|
||||
/** A JSON call that keeps the server's error text, which `_apiJson()` drops. */
|
||||
async _worktreeRequest(path, opts = {}) {
|
||||
const res = await this._api(path, opts);
|
||||
if (!res) return { ok: false, error: 'Codeman did not answer.' };
|
||||
let body = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
/* an empty or non-JSON body: the status says enough */
|
||||
}
|
||||
if (res.ok && body?.success !== false) return { ok: true, data: body?.data ?? body };
|
||||
return { ok: false, status: res.status, error: body?.error || `Request failed (${res.status})` };
|
||||
},
|
||||
|
||||
async _loadWorktrees() {
|
||||
const caseName = this._worktreeCase;
|
||||
if (!caseName) return;
|
||||
const seq = (this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1);
|
||||
this._setWorktreeStatus('Loading worktrees…');
|
||||
const r = await this._worktreeRequest(this._worktreesUrl(caseName));
|
||||
if (seq !== this._worktreeLoadSeq || caseName !== this._worktreeCase) return;
|
||||
this._worktreeData = r.ok ? r.data : null;
|
||||
this._setWorktreeStatus(r.ok ? '' : r.error, !r.ok);
|
||||
// A case that cannot list worktrees (not a repository, a Docker or remote case) cannot get one.
|
||||
const createBtn = document.getElementById('worktreeCreateBtn');
|
||||
if (createBtn) createBtn.disabled = !r.ok;
|
||||
this._renderWorktreeList();
|
||||
},
|
||||
|
||||
_setWorktreeStatus(text, isError = false) {
|
||||
const el = document.getElementById('worktreeStatus');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.hidden = !text;
|
||||
el.classList.toggle('worktree-status-error', !!isError);
|
||||
},
|
||||
|
||||
_setWorktreeFormError(text) {
|
||||
const el = document.getElementById('worktreeFormError');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.hidden = !text;
|
||||
},
|
||||
|
||||
/** Every enabled, installed run mode, the shell last; the Run button's mode preselected. */
|
||||
_fillWorktreeModeSelect() {
|
||||
const select = document.getElementById('worktreeMode');
|
||||
if (!select) return;
|
||||
// Every launchable registry entry (agents and the shell), as data: no id is branched on here.
|
||||
const modes = [];
|
||||
for (const cli of registryCliCatalog()) {
|
||||
if (cli.enabled && cli.available !== false) modes.push({ id: cli.id, label: cli.label });
|
||||
}
|
||||
if (modes.length === 0) {
|
||||
// A cached page from before the registry catalogue: the built-in modes.
|
||||
modes.push({ id: 'claude', label: 'Claude Code' });
|
||||
for (const [id, entry] of Object.entries(RUN_MODE_LAUNCH)) modes.push({ id, label: entry.label });
|
||||
modes.push({ id: 'shell', label: 'Terminal / Shell' });
|
||||
}
|
||||
select.replaceChildren(
|
||||
...modes.map((m) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = m.id;
|
||||
option.textContent = m.label;
|
||||
return option;
|
||||
})
|
||||
);
|
||||
const current = this._runMode || 'claude';
|
||||
select.value = modes.some((m) => m.id === current) ? current : modes[0].id;
|
||||
},
|
||||
|
||||
_selectedWorktreeMode() {
|
||||
return document.getElementById('worktreeMode')?.value || this._runMode || 'claude';
|
||||
},
|
||||
|
||||
_renderWorktreeList() {
|
||||
const list = document.getElementById('worktreeList');
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
const data = this._worktreeData;
|
||||
if (!data) return;
|
||||
for (const wt of data.worktrees) list.appendChild(this._buildWorktreeRow(wt));
|
||||
if (!data.worktrees.some((wt) => wt.managed)) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'worktree-empty';
|
||||
empty.textContent = 'No worktrees yet. Create one below.';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
},
|
||||
|
||||
_buildWorktreeRow(wt) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'worktree-row' + (wt.managed ? ' worktree-row--managed' : '');
|
||||
const head = document.createElement('div');
|
||||
head.className = 'worktree-row-head';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'worktree-row-name';
|
||||
label.setAttribute('data-i18n-skip', '');
|
||||
label.textContent = wt.managed ? `⑂ ${wt.name}` : wt.path.split('/').pop() || wt.path;
|
||||
head.appendChild(label);
|
||||
|
||||
const badge = (text, extra = '', skip = false) => {
|
||||
const b = document.createElement('span');
|
||||
b.className = `worktree-badge${extra ? ` ${extra}` : ''}`;
|
||||
if (skip) b.setAttribute('data-i18n-skip', '');
|
||||
b.textContent = text;
|
||||
head.appendChild(b);
|
||||
};
|
||||
if (wt.branch) badge(wt.branch, 'worktree-badge-branch', true);
|
||||
else if (wt.detached) badge('detached HEAD', 'worktree-badge-branch');
|
||||
if (wt.main) badge('main checkout');
|
||||
else if (!wt.managed) badge('not created by Codeman', 'worktree-badge-dim');
|
||||
if (wt.locked) badge('locked', 'worktree-badge-warn');
|
||||
if (wt.prunable) badge('missing', 'worktree-badge-warn');
|
||||
if (wt.sessionCount > 0) {
|
||||
badge(`${wt.sessionCount} session${wt.sessionCount === 1 ? '' : 's'}`, 'worktree-badge-live');
|
||||
}
|
||||
|
||||
if (wt.managed) {
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'worktree-row-actions';
|
||||
// A worktree whose folder is gone (`prunable`) cannot host a session, but Remove still
|
||||
// clears git's record of it, which is what frees its name for a new worktree.
|
||||
if (!wt.prunable) {
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'wt-btn wt-btn-sm';
|
||||
open.textContent = 'Open';
|
||||
open.title = 'Start a session in this worktree';
|
||||
open.addEventListener('click', () => void this.openWorktreeSession(wt.name));
|
||||
actions.appendChild(open);
|
||||
}
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'wt-btn wt-btn-sm wt-btn-danger';
|
||||
remove.textContent = 'Remove…';
|
||||
remove.addEventListener('click', () => this.confirmWorktreeRemoval(wt.name));
|
||||
actions.appendChild(remove);
|
||||
head.appendChild(actions);
|
||||
}
|
||||
row.appendChild(head);
|
||||
|
||||
// A managed worktree's folder is always `.claude/worktrees/<name>` of the case: say just that,
|
||||
// so the long repository prefix (already on the main row) is not repeated on every row.
|
||||
const path = document.createElement('div');
|
||||
path.className = 'worktree-row-path';
|
||||
path.setAttribute('data-i18n-skip', '');
|
||||
path.textContent = wt.managed ? `.claude/worktrees/${wt.name}` : this._shortenHomePath(wt.path);
|
||||
path.title = wt.path;
|
||||
row.appendChild(path);
|
||||
|
||||
if (wt.managed && this._worktreeRemoveConfirm === wt.name) row.appendChild(this._buildWorktreeRemoveConfirm(wt));
|
||||
return row;
|
||||
},
|
||||
|
||||
_buildWorktreeRemoveConfirm(wt) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'worktree-remove-confirm';
|
||||
const note = document.createElement('p');
|
||||
note.className = 'worktree-remove-note';
|
||||
note.textContent = wt.prunable
|
||||
? 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.'
|
||||
: 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.';
|
||||
box.appendChild(note);
|
||||
|
||||
let checkbox = null;
|
||||
if (wt.branch) {
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'worktree-remove-branch';
|
||||
checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = 'Also delete its branch (git branch -d keeps a branch that is not merged)';
|
||||
lbl.append(checkbox, text);
|
||||
box.appendChild(lbl);
|
||||
}
|
||||
|
||||
if (this._worktreeRemoveError?.name === wt.name) {
|
||||
const err = document.createElement('pre');
|
||||
err.className = 'worktree-remove-error';
|
||||
err.setAttribute('data-i18n-skip', '');
|
||||
err.textContent = this._worktreeRemoveError.text;
|
||||
box.appendChild(err);
|
||||
}
|
||||
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'worktree-remove-buttons';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'wt-btn wt-btn-sm';
|
||||
cancel.textContent = 'Cancel';
|
||||
cancel.addEventListener('click', () => this.cancelWorktreeRemoval());
|
||||
const confirm = document.createElement('button');
|
||||
confirm.type = 'button';
|
||||
confirm.className = 'wt-btn wt-btn-sm wt-btn-danger';
|
||||
confirm.textContent = 'Remove worktree';
|
||||
confirm.addEventListener('click', async () => {
|
||||
confirm.disabled = true;
|
||||
try {
|
||||
await this.removeWorktreeFromModal(wt.name, !!checkbox?.checked);
|
||||
} finally {
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
buttons.append(cancel, confirm);
|
||||
box.appendChild(buttons);
|
||||
return box;
|
||||
},
|
||||
|
||||
confirmWorktreeRemoval(name) {
|
||||
this._worktreeRemoveConfirm = name;
|
||||
this._worktreeRemoveError = null;
|
||||
this._renderWorktreeList();
|
||||
},
|
||||
|
||||
cancelWorktreeRemoval() {
|
||||
this._worktreeRemoveConfirm = null;
|
||||
this._worktreeRemoveError = null;
|
||||
this._renderWorktreeList();
|
||||
},
|
||||
|
||||
async removeWorktreeFromModal(name, deleteBranch) {
|
||||
const caseName = this._worktreeCase;
|
||||
if (!caseName) return;
|
||||
const r = await this.removeCaseWorktree(caseName, name, deleteBranch);
|
||||
if (caseName !== this._worktreeCase) return;
|
||||
if (!r.ok) {
|
||||
this._worktreeRemoveError = { name, text: r.error };
|
||||
this._renderWorktreeList();
|
||||
return;
|
||||
}
|
||||
this._worktreeRemoveConfirm = null;
|
||||
this._worktreeRemoveError = null;
|
||||
await this._loadWorktrees();
|
||||
},
|
||||
|
||||
/** DELETE one worktree (never forced); shared by the dialog and Close Session. Toasts the outcome. */
|
||||
async removeCaseWorktree(caseName, name, deleteBranch) {
|
||||
const query = deleteBranch ? '?deleteBranch=true' : '';
|
||||
const r = await this._worktreeRequest(this._worktreesUrl(caseName, `/${encodeURIComponent(name)}${query}`), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) {
|
||||
const removed = r.data?.removed || { name };
|
||||
if (removed.branchError) {
|
||||
this.showToast?.(
|
||||
`Worktree ${removed.name} removed. Branch ${removed.branch} kept: ${removed.branchError}`,
|
||||
'warning'
|
||||
);
|
||||
} else if (removed.branchDeleted) {
|
||||
this.showToast?.(`Worktree ${removed.name} and branch ${removed.branch} removed`, 'success');
|
||||
} else {
|
||||
this.showToast?.(`Worktree ${removed.name} removed`, 'success');
|
||||
}
|
||||
}
|
||||
return r;
|
||||
},
|
||||
|
||||
async createWorktreeFromModal() {
|
||||
const caseName = this._worktreeCase;
|
||||
if (!caseName) return;
|
||||
const name = (document.getElementById('worktreeName')?.value || '').trim();
|
||||
const branch = (document.getElementById('worktreeBranch')?.value || '').trim();
|
||||
const base = (document.getElementById('worktreeBase')?.value || '').trim();
|
||||
if (!WORKTREE_NAME_RE.test(name) || name.includes('..') || /\.(lock)?$/i.test(name)) {
|
||||
this._setWorktreeFormError(
|
||||
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._setWorktreeFormError('');
|
||||
const mode = this._selectedWorktreeMode();
|
||||
const btn = document.getElementById('worktreeCreateBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await this._worktreeRequest(this._worktreesUrl(caseName), {
|
||||
method: 'POST',
|
||||
body: { name, ...(branch ? { branch } : {}), ...(base ? { base } : {}) },
|
||||
});
|
||||
if (caseName !== this._worktreeCase) return;
|
||||
if (!r.ok) {
|
||||
this._setWorktreeFormError(r.error);
|
||||
return;
|
||||
}
|
||||
const created = r.data.worktree;
|
||||
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
|
||||
this.closeWorktreeModal();
|
||||
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openWorktreeSession(name) {
|
||||
const caseName = this._worktreeCase;
|
||||
const wt = this._worktreeData?.worktrees?.find((w) => w.managed && w.name === name);
|
||||
if (!caseName || !wt) return;
|
||||
const mode = this._selectedWorktreeMode();
|
||||
this.closeWorktreeModal();
|
||||
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
|
||||
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
|
||||
* entries use, so the Run button's chosen mode is untouched afterwards.
|
||||
*/
|
||||
async _launchInWorktree(worktree, mode) {
|
||||
if (this._runInFlight) {
|
||||
this.showToast?.('A launch is already in progress', 'info');
|
||||
return;
|
||||
}
|
||||
const previous = this._runMode;
|
||||
this._runMode = mode || previous;
|
||||
try {
|
||||
await this.run({ count: 1, worktree });
|
||||
} finally {
|
||||
this._runMode = previous;
|
||||
}
|
||||
},
|
||||
|
||||
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
|
||||
_managedWorktreeOfSession(session) {
|
||||
if (!session || session.remote || session.docker) return null;
|
||||
return managedWorktreeOfDir(session.workingDir, this.cases);
|
||||
},
|
||||
|
||||
/**
|
||||
* Close Session's "Kill & remove worktree", once the session is gone. git's refusal (a dirty
|
||||
* tree) is the answer: the worktree stays, and the toast says why and opens the dialog.
|
||||
*/
|
||||
async _removeWorktreeAfterClose(wt, deleteBranch) {
|
||||
const r = await this.removeCaseWorktree(wt.caseName, wt.name, deleteBranch);
|
||||
if (r.ok) {
|
||||
if (this._worktreeCase === wt.caseName) void this._loadWorktrees();
|
||||
return;
|
||||
}
|
||||
this.showToast?.(`Worktree ${wt.name} was kept: ${r.error}`, 'error', {
|
||||
duration: 15000,
|
||||
action: { label: 'Worktrees…', onClick: () => this.openWorktreeModal(wt.caseName) },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
|
||||
@@ -442,6 +442,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||
// Spawned tabs follow their parent: synced (the server places the tab), default OFF.
|
||||
document.getElementById('appSettingsSpawnedTabsFollowParent').checked = settings.spawnedTabsFollowParent === true;
|
||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
@@ -510,6 +512,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked =
|
||||
this.tabDetachButtonEnabled?.(settings, defaults)
|
||||
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
|
||||
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
|
||||
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
|
||||
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
|
||||
const tileDetachItem = document.getElementById('appSettingsTileDetachItem');
|
||||
if (tileDetachItem) tileDetachItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||
@@ -2582,6 +2589,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||
spawnedTabsFollowParent: document.getElementById('appSettingsSpawnedTabsFollowParent').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
@@ -2616,6 +2624,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
document.getElementById('appSettingsSessionSidebarFontSize').value
|
||||
@@ -2867,6 +2876,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitStatusMaxRepos: _gsm,
|
||||
gitStatusTimeoutSeconds: _gst2,
|
||||
showTabDetachButton: _tdb,
|
||||
// Detach Tiles: what a tile drag does is a property of this device's
|
||||
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
|
||||
tileDetachEnabled: _tde,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
// Desktop-only tab decoration, per-device, and likewise absent from the
|
||||
@@ -3902,6 +3914,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Tiles button: same gate and backstop as Split (tile-grid.js).
|
||||
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
|
||||
this._applyTileGridButtonVisibility?.(showTileGridButton);
|
||||
// Detach Tiles changes what a tile's header does (and says): repaint the handles.
|
||||
this._renderTileChrome?.();
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
@@ -4358,6 +4372,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
'showTileGridButton',
|
||||
'tileDetachEnabled',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
|
||||
@@ -171,6 +171,17 @@ async function loadWorkbook(bytes) {
|
||||
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||
// than silently expanding again.
|
||||
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
|
||||
// ExcelJS follows a worksheet's comment, VML, table and drawing links
|
||||
// unchecked, and openpyxl spells them so the lookup finds nothing: one comment,
|
||||
// table or chart failed the whole preview. `load()` calls `this.reconcile()`
|
||||
// once on the parsed model; repair the model first (core.repairExcelJsModel).
|
||||
// Throws if a future ExcelJS renames reconcile, rather than failing again.
|
||||
const reconcile = nextWorkbook.xlsx.reconcile;
|
||||
if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable');
|
||||
nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) {
|
||||
core.repairExcelJsModel(model);
|
||||
return reconcile.call(this, model, options);
|
||||
};
|
||||
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||
// object (a whole-column dropdown is a million), and the preview never shows
|
||||
// validations, so they are not parsed at all. `maxRows` is a per-sheet
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||
const SPREADSHEET_ASSET_VERSION = 'fd81c01bef7b';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
|
||||
@@ -472,6 +472,115 @@
|
||||
return zipApi.zipSync(admission.entries, { level: 0 });
|
||||
}
|
||||
|
||||
// ExcelJS (4.4.0, lib/xlsx/xlsx.js + xform/sheet/worksheet-xform.js `reconcile`)
|
||||
// links a worksheet to its comments, comment notes (VML), tables and drawing by
|
||||
// looking the relationship Target up VERBATIM in maps keyed by its OWN spelling
|
||||
// (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`,
|
||||
// `drawing1`), then dereferences the result unchecked. openpyxl, which most
|
||||
// agents write spreadsheets with, uses absolute Targets, its own part names
|
||||
// (`/xl/comments/comment1.xml`) and a drawing XML ExcelJS parses to nothing, so
|
||||
// a workbook with ONE comment, table or chart threw ("reading 'comments'",
|
||||
// "'name'", "'anchors'") before a single cell was shown.
|
||||
//
|
||||
// This runs on ExcelJS's parsed model right before that reconcile. It only
|
||||
// re-points a link to the key ExcelJS itself stored for the same part, or drops
|
||||
// a link (or a drawing) that would dereference nothing; nothing is added. The
|
||||
// preview shows none of these parts. Lookups are own-property only, so a Target
|
||||
// or rId such as `__proto__` cannot resolve through Object.prototype.
|
||||
const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
|
||||
const REL_COMMENTS = `${REL_TYPE}comments`;
|
||||
const REL_VML_DRAWING = `${REL_TYPE}vmlDrawing`;
|
||||
const REL_TABLE = `${REL_TYPE}table`;
|
||||
const EXCELJS_DRAWING_TARGET = /\/drawings\/([a-zA-Z0-9]+)[.][a-zA-Z]{3,4}$/;
|
||||
|
||||
function ownValue(map, key) {
|
||||
return map && typeof key === 'string' && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
|
||||
}
|
||||
|
||||
// The key ExcelJS gives a part it loaded from `xl/<path>` is `../<path>`
|
||||
// (relative to xl/worksheets/). A Target that names the same part another way
|
||||
// (`/xl/tables/table1.xml`) is rewritten to that key.
|
||||
function worksheetRelativeTarget(target) {
|
||||
const path = excelJsEntryName(target[0] === '/' ? target : `xl/worksheets/${target}`);
|
||||
return path.startsWith('xl/') ? `../${path.slice(3)}` : null;
|
||||
}
|
||||
|
||||
function resolveTarget(rel, map, usable) {
|
||||
if (typeof rel.Target !== 'string') return false;
|
||||
if (usable(ownValue(map, rel.Target))) return true;
|
||||
const key = worksheetRelativeTarget(rel.Target);
|
||||
if (key && usable(ownValue(map, key))) {
|
||||
rel.Target = key;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function repairExcelJsModel(model) {
|
||||
if (!model || typeof model !== 'object') return model;
|
||||
const isObject = (value) => Boolean(value) && typeof value === 'object';
|
||||
const drawings = isObject(model.drawings) ? model.drawings : {};
|
||||
// ExcelJS reconciles every drawing that has rels and reads `.anchors` off it.
|
||||
for (const name of Object.keys(drawings)) {
|
||||
if (!isObject(drawings[name]) || !Array.isArray(drawings[name].anchors)) {
|
||||
delete drawings[name];
|
||||
if (isObject(model.drawingRels)) delete model.drawingRels[name];
|
||||
}
|
||||
}
|
||||
// ExcelJS also reconciles every table it parsed, so one it parsed to nothing goes.
|
||||
if (isObject(model.tables)) {
|
||||
for (const key of Object.keys(model.tables)) if (!isObject(model.tables[key])) delete model.tables[key];
|
||||
}
|
||||
const worksheetRels = isObject(model.worksheetRels) ? model.worksheetRels : {};
|
||||
// Worksheets can share a sheetNo (ExcelJS's worksheet pattern is unanchored),
|
||||
// and then they share one rels list: filter and index each list ONCE, or a
|
||||
// long list costs once per worksheet.
|
||||
const relsBySheetNo = new Map();
|
||||
for (const worksheet of Array.isArray(model.worksheets) ? model.worksheets : []) {
|
||||
if (!isObject(worksheet)) continue;
|
||||
const sheetNo = String(worksheet.sheetNo);
|
||||
let byId = relsBySheetNo.get(sheetNo);
|
||||
if (!byId) {
|
||||
const list = ownValue(worksheetRels, sheetNo);
|
||||
const rels = Array.isArray(list) ? list : [];
|
||||
const kept = rels.filter((rel) => {
|
||||
if (!isObject(rel)) return false;
|
||||
if (rel.Type === REL_COMMENTS) return resolveTarget(rel, model.comments, isObject);
|
||||
if (rel.Type === REL_VML_DRAWING) {
|
||||
return resolveTarget(rel, model.vmlDrawings, (vml) => isObject(vml) && Array.isArray(vml.comments));
|
||||
}
|
||||
if (rel.Type === REL_TABLE) return resolveTarget(rel, model.tables, isObject);
|
||||
return true;
|
||||
});
|
||||
if (Array.isArray(list)) worksheetRels[sheetNo] = kept;
|
||||
byId = new Map();
|
||||
for (const rel of kept) byId.set(rel.Id, rel);
|
||||
relsBySheetNo.set(sheetNo, byId);
|
||||
}
|
||||
// `<tablePart r:id>`, `<drawing r:id>` and `<hyperlink r:id>` are followed
|
||||
// through the rels unchecked, so each must name a rel that is still there.
|
||||
if (Array.isArray(worksheet.tables)) {
|
||||
worksheet.tables = worksheet.tables.filter((part) => {
|
||||
const rel = isObject(part) ? byId.get(part.rId) : undefined;
|
||||
return Boolean(rel) && rel.Type === REL_TABLE;
|
||||
});
|
||||
}
|
||||
if (isObject(worksheet.drawing)) {
|
||||
const rel = byId.get(worksheet.drawing.rId);
|
||||
const match = rel && typeof rel.Target === 'string' ? rel.Target.match(EXCELJS_DRAWING_TARGET) : null;
|
||||
if (!rel || typeof rel.Target !== 'string' || (match && !ownValue(drawings, match[1]))) {
|
||||
worksheet.drawing = undefined;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(worksheet.hyperlinks)) {
|
||||
worksheet.hyperlinks = worksheet.hyperlinks.filter(
|
||||
(link) => isObject(link) && (!link.rId || byId.has(link.rId))
|
||||
);
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function parseCellRef(ref) {
|
||||
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
|
||||
if (!match) return null;
|
||||
@@ -983,6 +1092,7 @@
|
||||
createXmlCounter,
|
||||
admitXlsx,
|
||||
buildAdmittedArchive,
|
||||
repairExcelJsModel,
|
||||
excelJsEntryName,
|
||||
parseCellRef,
|
||||
parseRange,
|
||||
|
||||
@@ -716,6 +716,19 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* A tab just arrived inside this collapsed group: a short highlight on its header. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
transition: background-color 0.6s ease-out, color 0.6s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
@@ -815,6 +828,141 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layo
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* --- Focus section (pinned shortcuts, app.js _renderTabFocus) ---------- */
|
||||
/* Its own element above #sessionTabs in the rail, so it shows above the flat
|
||||
rail and the grouped one alike and never takes part in the list's order,
|
||||
tree or Alt+N numbering. Capped so a long Focus never starves the list. */
|
||||
.tab-focus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus:not([hidden]) {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 40%;
|
||||
min-height: 0;
|
||||
padding: 0.35rem 0.35rem 0.3rem calc(0.35rem + 24px);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 4px 6px 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail :is(.tab-focus-toggle, .tab-focus-item):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab-focus-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-focus-count {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-focus--collapsed .tab-layout-group-chevron::before {
|
||||
content: '\25B8';
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, var(--text-muted));
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item[aria-current='true'] {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.tab-focus-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The tab's own status dot and alert colour, so a shortcut says what its tab
|
||||
says (the row keeps its full alert ring; a shortcut only recolours the dot). */
|
||||
.tab-focus-item .tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--green);
|
||||
}
|
||||
.tab-focus-item .tab-status.error { background: var(--red); }
|
||||
.tab-focus-item .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
|
||||
.tab-focus-item.tab-alert-action .tab-status { background: var(--red); box-shadow: 0 0 6px rgba(239, 68, 68, 0.7); }
|
||||
.tab-focus-item.tab-alert-idle .tab-status { background: var(--yellow); box-shadow: 0 0 6px rgba(234, 179, 8, 0.6); }
|
||||
.tab-focus-item .tab-web-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-dim, var(--text-muted));
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -2927,6 +3075,14 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* A child session's placement note: information, not an action (no focus, no hover). */
|
||||
.tab-rail-action-menu .tab-rail-action-menu-note {
|
||||
padding: 0.45rem 0.6rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(112px, 1fr) 44px;
|
||||
@@ -2990,6 +3146,14 @@ body.solo-mode .btn-lifecycle-log {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Detach Tiles: the title is a handle, dragged onto a Codeman window's tiles
|
||||
to dock the session there (tile-grid.js _installSoloTileHandle). */
|
||||
.solo-session-title.solo-session-title--handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.solo-session-title.solo-session-title--handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
/* "Session unavailable" overlay for a solo window whose session has ended. */
|
||||
.solo-gone-overlay {
|
||||
position: fixed;
|
||||
@@ -5996,6 +6160,12 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile Attach button (wrapper holds the button and its file input) - hidden on
|
||||
desktop; the phone block in mobile.css shows it */
|
||||
.btn-attach-mobile-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile case settings gear - hidden on desktop */
|
||||
.btn-case-settings-mobile {
|
||||
display: none;
|
||||
@@ -7623,6 +7793,9 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.mobile-case-picker-footer {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
/* Create New Case and Worktrees share the row, half each. */
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-case-create-mobile {
|
||||
@@ -7978,6 +8151,249 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Close Session: "Kill & remove worktree" (a session in a Codeman-managed git
|
||||
worktree only). The wrapper re-asserts [hidden]: it lays out as a column. */
|
||||
.close-option-worktree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.close-option-worktree[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.close-option-worktree-branch {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-option-worktree-branch input {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Git worktrees dialog (session-ui.js openWorktreeModal). Its own small button
|
||||
set: the skin block greys `.btn-toolbar.btn-primary` on non-OG skins, and this
|
||||
dialog needs a primary action that reads as one on every skin. */
|
||||
.modal-content.worktree-modal {
|
||||
max-width: 640px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.worktree-modal > .modal-body {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.worktree-modal-case {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.worktree-intro {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.worktree-status {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.6rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-status[hidden],
|
||||
.worktree-form-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.worktree-status-error,
|
||||
.worktree-form-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.worktree-form-error {
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.worktree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.worktree-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--control-bg);
|
||||
}
|
||||
.worktree-row--managed {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.worktree-row-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.45rem;
|
||||
}
|
||||
.worktree-row-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-badge {
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.5;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.worktree-badge-branch {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.35);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-badge-dim {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.worktree-badge-warn {
|
||||
color: var(--yellow);
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
.worktree-badge-live {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.worktree-row-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.worktree-row-path {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worktree-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0.1rem;
|
||||
}
|
||||
.worktree-remove-confirm {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.worktree-remove-note {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.worktree-remove-branch {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.worktree-remove-error {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--red);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: rgba(224, 99, 99, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
.worktree-remove-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.worktree-mode-row {
|
||||
max-width: 320px;
|
||||
}
|
||||
.worktree-new {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem 0.4rem;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.worktree-new > legend {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
padding: 0 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.worktree-advanced {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.worktree-advanced > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.wt-btn {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: var(--btn-radius);
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-smooth), border-color var(--transition-smooth);
|
||||
}
|
||||
.wt-btn:hover:not(:disabled) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
}
|
||||
.wt-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.wt-btn-sm {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
.wt-btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wt-btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
.wt-btn-danger {
|
||||
color: var(--red);
|
||||
border-color: rgba(224, 99, 99, 0.45);
|
||||
}
|
||||
.wt-btn-danger:hover:not(:disabled) {
|
||||
background: rgba(224, 99, 99, 0.12);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.modal-session-name {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.8rem;
|
||||
@@ -11375,6 +11791,132 @@ kbd {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Folder grid (panels-ui.js openFolderGrid): one step BELOW the file preview, so a
|
||||
tile opens its preview on top and closing that preview lands back on the grid. */
|
||||
.folder-grid-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--modal-backdrop);
|
||||
z-index: 5090;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.folder-grid-overlay.visible {
|
||||
display: flex;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.folder-grid-window {
|
||||
width: 92vw;
|
||||
max-width: 1400px;
|
||||
height: 88vh;
|
||||
background: var(--floating-bg);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--elevated-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.folder-grid-folders {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 6.5rem;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-grid-folders:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-grid-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-grid-chip:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.folder-grid-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-dark);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
grid-auto-rows: min-content;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.folder-grid-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folder-grid-tile:hover,
|
||||
.folder-grid-tile.current {
|
||||
border-color: var(--accent, #4ea1ff);
|
||||
}
|
||||
|
||||
.folder-grid-thumb {
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.folder-grid-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.folder-grid-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-grid-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.file-preview-window {
|
||||
width: 80vw;
|
||||
max-width: 900px;
|
||||
@@ -19386,7 +19928,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
|
||||
/* No gutter of their own. */
|
||||
.modal,
|
||||
.file-preview-overlay {
|
||||
.file-preview-overlay,
|
||||
.folder-grid-overlay {
|
||||
padding-right: var(--fold-inline-end);
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ self.addEventListener('fetch', (event) => {
|
||||
if (request.headers.get('upgrade') === 'websocket') return;
|
||||
if (request.headers.get('accept') === 'text/event-stream') return;
|
||||
if (request.url.includes('/api/')) return;
|
||||
// A rendered HTML page is served `no-store` behind a capability that logout
|
||||
// revokes; caching a popped-out page here would let the offline fallback
|
||||
// serve it after the capability is gone.
|
||||
if (request.url.includes('/html-view/')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||
* read degrades to the flat rail with a capped, backed-off retry.
|
||||
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
|
||||
* row) applied optimistically and saved through ONE serialized
|
||||
* row, pin a row to Focus) applied optimistically and saved through ONE serialized
|
||||
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
|
||||
* version conflict.
|
||||
*
|
||||
@@ -32,17 +32,26 @@
|
||||
'use strict';
|
||||
|
||||
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
|
||||
// The Focus section's collapse, per device. Its own key, not an entry in the
|
||||
// group list above: that list is garbage-collected against the layout's group
|
||||
// ids, and Focus is not a group.
|
||||
const FOCUS_COLLAPSED_STORAGE_KEY = 'codeman:tab-focus-collapsed';
|
||||
|
||||
const refKey = (ref) => `${ref.kind}:${ref.id}`;
|
||||
const validRef = (ref) =>
|
||||
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||
const stableIds = (value) => [...new Set(asIds(value))];
|
||||
// `placement: 'manual'` must survive the round trip: the browser writes whole
|
||||
// layouts back, and dropping it would re-attach a hand-placed child session to
|
||||
// its parent's subtree on the next save.
|
||||
const copyRef = (r) =>
|
||||
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
|
||||
// `placement: 'manual'` and `focus: true` must survive the round trip: the
|
||||
// browser writes whole layouts back, so a field dropped here is erased on the
|
||||
// next save, by whoever saves. Losing placement re-attaches a hand-placed child
|
||||
// session to its parent's subtree; losing focus unpins every Focus shortcut.
|
||||
const copyRef = (r) => {
|
||||
const copy = { kind: r.kind, id: r.id };
|
||||
if (r.placement === 'manual') copy.placement = 'manual';
|
||||
if (r.focus === true) copy.focus = true;
|
||||
return copy;
|
||||
};
|
||||
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
|
||||
|
||||
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
|
||||
@@ -108,6 +117,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Focus section's per-device collapse. `ok: false` means the store threw;
|
||||
* any value other than 'true' reads as expanded.
|
||||
*/
|
||||
function loadFocusCollapsed(storage) {
|
||||
try {
|
||||
return { collapsed: storage.getItem(FOCUS_COLLAPSED_STORAGE_KEY) === 'true', ok: true };
|
||||
} catch (_error) {
|
||||
return { collapsed: false, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function saveFocusCollapsed(storage, collapsed) {
|
||||
try {
|
||||
storage.setItem(FOCUS_COLLAPSED_STORAGE_KEY, collapsed === true ? 'true' : 'false');
|
||||
return { collapsed: collapsed === true, ok: true };
|
||||
} catch (_error) {
|
||||
return { collapsed: false, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refs pinned to the Focus section that this browser can draw: focused,
|
||||
* a live session or an open web tab, each once, in stored order (groups in
|
||||
* order, then Ungrouped). Works with or without named groups, so Focus also
|
||||
* shows above the flat rail. A ref whose session closed or whose web tab is
|
||||
* not open here simply drops out; the server prunes deleted refs on its own.
|
||||
*/
|
||||
function focusRefs(layoutInput, options = {}) {
|
||||
if (!layoutInput) return [];
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
const live = new Set(asIds(options.liveSessionIds));
|
||||
const open = new Set(asIds(options.openWebviewIds));
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const ref of [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]) {
|
||||
if (ref.focus !== true) continue;
|
||||
if (!(ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id))) continue;
|
||||
const key = refKey(ref);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({ kind: ref.kind, id: ref.id });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function saveCollapsedGroupIds(storage, groupIds) {
|
||||
const ids = stableIds(groupIds);
|
||||
try {
|
||||
@@ -131,11 +186,19 @@
|
||||
* tab, else the active session), so selecting a hidden session by keyboard,
|
||||
* palette or Alt+N never leaves the user with no visible selection.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
|
||||
* `options.parents` (child session id -> parent session id) places a live
|
||||
* session the layout has not stored yet right after its parent's rows when
|
||||
* the parent IS stored: that is where the server puts a child that follows its
|
||||
* parent, so a spawned session appears in its parent's group at once instead
|
||||
* of jumping there from Ungrouped once the layout re-read lands.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs }}
|
||||
* null when the layout has no groups: the caller renders the flat rail
|
||||
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
|
||||
* collapse hides (`hidden`); `sectionByRef` maps every placed row
|
||||
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
|
||||
* `focusRefs` is focusRefs() for the same options: shortcuts, never rows,
|
||||
* so they are in none of the other fields.
|
||||
*/
|
||||
function project(layoutInput, options = {}) {
|
||||
if (!layoutInput) return null;
|
||||
@@ -152,6 +215,7 @@
|
||||
? `session:${options.activeSessionId}`
|
||||
: '';
|
||||
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
|
||||
const withFollowers = followingChildren(layout, liveSessionIds, options.parents);
|
||||
const placed = new Set();
|
||||
const visibleRefs = [];
|
||||
const hiddenTabGroupByRef = {};
|
||||
@@ -182,14 +246,14 @@
|
||||
|
||||
for (const group of layout.groups) {
|
||||
const isCollapsed = collapsed.has(group.id);
|
||||
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
|
||||
const { shown, hidden, count } = place(withFollowers(group.refs), group.id, isCollapsed);
|
||||
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
|
||||
}
|
||||
const omissions = [
|
||||
...liveSessionIds.map((id) => ({ kind: 'session', id })),
|
||||
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
|
||||
];
|
||||
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
|
||||
const ungrouped = place([...withFollowers(layout.ungrouped), ...omissions], null, false);
|
||||
if (ungrouped.count > 0) {
|
||||
sections.push({
|
||||
id: null,
|
||||
@@ -200,7 +264,7 @@
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) };
|
||||
}
|
||||
|
||||
const ALERT_RANK = { action: 2, idle: 1 };
|
||||
@@ -227,6 +291,47 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* For project(): returns `expand(refs)`, which inserts each live, not-yet-stored
|
||||
* child session right after its parent's rows in the parent's container. A
|
||||
* child of such a child follows it the same way. Without `parents` it is the
|
||||
* identity, so callers that pass no lineage see no change.
|
||||
*/
|
||||
function followingChildren(layout, liveSessionIds, parents) {
|
||||
if (!parents || typeof parents !== 'object') return (refs) => refs;
|
||||
const stored = new Set(refLocations(layout).map((item) => refKey(item.ref)));
|
||||
const accepted = new Set();
|
||||
const pending = new Map();
|
||||
// Accept parents before their children (bounded: one pass per accepted child).
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const id of liveSessionIds) {
|
||||
const key = `session:${id}`;
|
||||
const parentId = parents[id];
|
||||
if (stored.has(key) || accepted.has(key) || typeof parentId !== 'string' || parentId === id) continue;
|
||||
const parentKey = `session:${parentId}`;
|
||||
if (!stored.has(parentKey) && !accepted.has(parentKey)) continue;
|
||||
accepted.add(key);
|
||||
if (!pending.has(parentKey)) pending.set(parentKey, []);
|
||||
pending.get(parentKey).push({ kind: 'session', id });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!pending.size) return (refs) => refs;
|
||||
return (refs) => {
|
||||
const out = [...refs];
|
||||
for (const [parentKey, children] of pending) {
|
||||
const block = lineageBlock(layout, { kind: 'session', id: parentKey.slice(8) }, parents);
|
||||
let at = -1;
|
||||
out.forEach((ref, index) => {
|
||||
if (block.has(refKey(ref))) at = index;
|
||||
});
|
||||
if (at >= 0) out.splice(at + 1, 0, ...children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
|
||||
* where, the headers' names, what a collapse hides), as opposed to a row's own
|
||||
@@ -418,6 +523,35 @@
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a child session is placed, for the row menu. null for a session with no
|
||||
* parent (or not stored). Otherwise `{ state, parentId, canFollow }`:
|
||||
* - inherited: it follows its parent (moves with it).
|
||||
* - manual: placed by hand; "Follow parent again" puts it back.
|
||||
* - dangling: its parent is not in this layout (closed, or another owner's),
|
||||
* so it stands on its own; nothing re-adopts it automatically.
|
||||
* - cycle: the parent chain loops back to it; it stands on its own.
|
||||
*/
|
||||
function placementState(layoutInput, ref, parents) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
if (!validRef(ref) || ref.kind !== 'session') return null;
|
||||
const parentId = parents?.[ref.id];
|
||||
if (typeof parentId !== 'string' || !parentId || parentId === ref.id) return null;
|
||||
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
|
||||
const own = stored.get(refKey(ref));
|
||||
if (!own) return null;
|
||||
if (!stored.has(`session:${parentId}`)) return { state: 'dangling', parentId, canFollow: false };
|
||||
// Walk up the stored ancestors; meeting this session again is a loop.
|
||||
const seen = new Set([ref.id]);
|
||||
for (let id = parentId; id && stored.has(`session:${id}`); id = parents[id]) {
|
||||
if (seen.has(id)) return { state: 'cycle', parentId, canFollow: false };
|
||||
seen.add(id);
|
||||
}
|
||||
return own.placement === 'manual'
|
||||
? { state: 'manual', parentId, canFollow: true }
|
||||
: { state: 'inherited', parentId, canFollow: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a moved row lands, as the server's `index` (counted AFTER the moved
|
||||
* block is taken out): before or after `anchor` in that container, or at its
|
||||
@@ -526,6 +660,17 @@
|
||||
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
|
||||
return layout;
|
||||
}
|
||||
case 'setFocus': {
|
||||
// Pin or unpin one row. It never moves: Focus draws a shortcut, and the
|
||||
// row stays in its group. Idempotent, so a replay after a 409 is safe.
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
if (typeof op.focused !== 'boolean') editError('invalid Focus state');
|
||||
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(op.ref));
|
||||
if (!location) editError('unknown row');
|
||||
if (op.focused) location.ref.focus = true;
|
||||
else delete location.ref.focus;
|
||||
return layout;
|
||||
}
|
||||
case 'moveRef': {
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
const targetKey = refKey(op.ref);
|
||||
@@ -546,6 +691,43 @@
|
||||
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
|
||||
return layout;
|
||||
}
|
||||
case 'followParent': {
|
||||
// Clear a hand placement: the child (with the sessions that follow it)
|
||||
// goes back after its parent's rows. The server re-derives the exact
|
||||
// order on save; this only has to put the row in the right group now.
|
||||
const state = placementState(layout, op.ref, op.parents);
|
||||
if (!state || state.state === 'dangling' || state.state === 'cycle') editError('no parent to follow');
|
||||
if (state.state !== 'manual') return layout;
|
||||
const targetKey = refKey(op.ref);
|
||||
// Drop the placement only: a Focus pin stays (the whole layout is PUT,
|
||||
// so a field left off here would be erased on save).
|
||||
const clear = (ref) => {
|
||||
if (refKey(ref) !== targetKey) return ref;
|
||||
const copy = copyRef(ref);
|
||||
delete copy.placement;
|
||||
return copy;
|
||||
};
|
||||
for (const group of layout.groups) group.refs = group.refs.map(clear);
|
||||
layout.ungrouped = layout.ungrouped.map(clear);
|
||||
const childKeys = new Set();
|
||||
for (const key of lineageBlock(layout, op.ref, op.parents)) childKeys.add(key);
|
||||
const block = refLocations(layout)
|
||||
.filter((item) => childKeys.has(refKey(item.ref)))
|
||||
.map((item) => copyRef(item.ref));
|
||||
block.sort((a, b) => (refKey(a) === targetKey ? -1 : refKey(b) === targetKey ? 1 : 0));
|
||||
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !childKeys.has(refKey(ref)));
|
||||
layout.ungrouped = layout.ungrouped.filter((ref) => !childKeys.has(refKey(ref)));
|
||||
const parentRef = { kind: 'session', id: state.parentId };
|
||||
const home = refLocations(layout).find((item) => refKey(item.ref) === refKey(parentRef));
|
||||
const container = containerRefs(layout, home.groupId);
|
||||
const parentBlock = lineageBlock(layout, parentRef, op.parents);
|
||||
let at = -1;
|
||||
container.forEach((ref, index) => {
|
||||
if (parentBlock.has(refKey(ref))) at = index;
|
||||
});
|
||||
container.splice(at + 1, 0, ...block);
|
||||
return layout;
|
||||
}
|
||||
default:
|
||||
return editError(`unknown operation ${op.type}`);
|
||||
}
|
||||
@@ -739,12 +921,16 @@
|
||||
applyOperation,
|
||||
moveDestination,
|
||||
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
|
||||
placementState,
|
||||
dropOperation,
|
||||
contentKey,
|
||||
createEditCoordinator,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
focusRefs,
|
||||
loadFocusCollapsed,
|
||||
saveFocusCollapsed,
|
||||
MAX_GROUPS,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -327,15 +327,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const actions = [
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
// A tile (Detach Tiles on) opens its own size, over where it is: the
|
||||
// keyboard's way to what dragging its header out of the window does.
|
||||
...(this._tileGrid?.has?.(sessionId) && this._tileDetachAllowed?.()
|
||||
? [{ label: 'Open in a new window', run: () => this.detachTile(sessionId) }]
|
||||
: (this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||
];
|
||||
for (const action of actions) {
|
||||
// A note (a child session's placement) is plain text, not a menu item: it
|
||||
// takes no focus and no arrow stop, and describes the menu instead.
|
||||
if (action.note) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'tab-rail-action-menu-note';
|
||||
note.setAttribute('role', 'none');
|
||||
note.id = 'tabRailActionMenuNote';
|
||||
note.textContent = action.label;
|
||||
menu.setAttribute('aria-describedby', note.id);
|
||||
menu.appendChild(note);
|
||||
continue;
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.setAttribute('role', 'menuitem');
|
||||
|
||||
@@ -25,13 +25,15 @@
|
||||
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
|
||||
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
|
||||
* primary pane does, since xterm turns it into a horizontal no-op.
|
||||
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
|
||||
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
|
||||
* so the wheel pages the CLI's own transcript with PageUp/PageDown
|
||||
* (_maybePageCliTranscript) through the primary pane's gates, plus an
|
||||
* overflow-row discount for this pane's capture-before-resize load
|
||||
* (_localRows), and only while the viewport is on the live screen (a
|
||||
* wheel-down from those overflow rows is xterm's, and brings it home).
|
||||
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex
|
||||
* on the alternate screen, Claude's repaint mode) leaves the xterm no
|
||||
* scrollback, so the wheel pages the CLI's own transcript with
|
||||
* PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates
|
||||
* and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus
|
||||
* an overflow-row discount for this pane's capture-before-resize load
|
||||
* (_localRows), only while the viewport is on the live screen (a wheel-down
|
||||
* from those overflow rows is xterm's, and brings it home), and never while
|
||||
* THIS pane's session has a dialog open.
|
||||
* - The desktop click report: a plain left-click hand-encoded as SGR while
|
||||
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
|
||||
* whose mouse DECSETs the server strips (_installClickListener), sent
|
||||
@@ -48,8 +50,8 @@
|
||||
* Android tablet, or a large foldable unfolded in landscape, reaches.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
|
||||
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
*/
|
||||
@@ -204,9 +206,10 @@
|
||||
// flag, app._linkHovered, belongs to its terminal alone).
|
||||
this._linkHovered = false;
|
||||
this._onFocusIn = null;
|
||||
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
|
||||
// whole page, carried to the next wheel event.
|
||||
this._pageKeyPending = 0;
|
||||
// Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress
|
||||
// (travel short of a whole page, its direction and last event time), the
|
||||
// state CodemanTerminalInput.pageKeysForGesture hands back.
|
||||
this._pageKeyGesture = null;
|
||||
// Shift+wheel travel short of a whole line, carried to the next wheel
|
||||
// event (_maybeScrollLocalOnShift).
|
||||
this._shiftScrollPending = 0;
|
||||
@@ -243,6 +246,9 @@
|
||||
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
// OSC 52 clipboard writes from this pane's PTY, gated on THIS pane's
|
||||
// buffer load so a replay never re-copies (terminal-ui.js).
|
||||
global.app?._installOsc52Clipboard?.(this.terminal, () => this._bufferLoading);
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
@@ -770,8 +776,15 @@
|
||||
try {
|
||||
if (this._destroyed) return;
|
||||
const shell = this.sessionMode === 'shell';
|
||||
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
|
||||
// A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
|
||||
// constants.js), never the raw byte recording; a grid tile bounds it
|
||||
// by its own scrollback instead.
|
||||
let query = shell ? SHELL_LOAD_QUERY : 'full=1';
|
||||
if (this.boundedLoad) {
|
||||
query = shell
|
||||
? `${SHELL_LOAD_BASE_QUERY}${this._historyLinesQuery()}`
|
||||
: `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
|
||||
}
|
||||
this._loadAbort = controller;
|
||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
|
||||
let payload;
|
||||
@@ -1147,17 +1160,20 @@
|
||||
|
||||
// Hollow-buffer paging, the twin of the primary pane's
|
||||
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
|
||||
// draws in place (opencode, on the alternate screen; Claude's repaint mode)
|
||||
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
|
||||
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is
|
||||
// the primary pane's own, asked for THIS pane (its terminal, its session,
|
||||
// never the active one), so the CLI rules stay in terminal-ui.js and this
|
||||
// file names no CLI. Returns true when the wheel was consumed here.
|
||||
// draws in place (on the alternate screen, or Claude's repaint mode) leaves
|
||||
// this xterm no scrollback, so a wheel scrolled nothing; instead the travel
|
||||
// pages the CLI's own transcript with PageUp/PageDown. Every gate is the
|
||||
// primary pane's own, asked for THIS pane (its terminal, its session, never
|
||||
// the active one), so the CLI rules stay in terminal-ui.js and this file
|
||||
// names no CLI. The gesture rules (first-event page, notch accumulation,
|
||||
// pinch and sideways-swipe consume) are the shared pure
|
||||
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
|
||||
// Returns true when the wheel was consumed here.
|
||||
_maybePageCliTranscript(ev) {
|
||||
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
|
||||
const app = global.app;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
|
||||
if (!app || !input?.pageKeysForGesture || !input.wheelDeltaLines) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it (a shell running htop), as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
@@ -1181,8 +1197,12 @@
|
||||
if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
|
||||
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
if (!lines) return false;
|
||||
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
|
||||
this._pageKeyPending = step.pending;
|
||||
// An open dialog (a pending permission prompt or question) in THIS tile's
|
||||
// session, which is usually not the active one: a page key would move its
|
||||
// selector. Consumed, so xterm does not scroll the rows under it either.
|
||||
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
|
||||
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
|
||||
this._pageKeyGesture = step.state;
|
||||
if (step.keys) this._queueScrollBytes(step.keys);
|
||||
return true;
|
||||
}
|
||||
@@ -1536,7 +1556,7 @@
|
||||
clearTimeout(this._scrollFlushTimer);
|
||||
this._scrollFlushTimer = null;
|
||||
this._scrollBytes = '';
|
||||
this._pageKeyPending = 0;
|
||||
this._pageKeyGesture = null;
|
||||
this._detachSocket();
|
||||
if (this._onFocusIn) {
|
||||
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions)
|
||||
* @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants)
|
||||
* @dependency constants.js (DEC_SYNC_STRIP_RE, OSC52_STRIP_RE, OSC52_DEDUPE_MS, TIMING constants)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
|
||||
* @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay)
|
||||
@@ -79,7 +79,7 @@
|
||||
const REPLAY_ESCAPE_RE =
|
||||
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?<>=!]*[ -/]*[@-~]|\x1b[()#][0-9A-Za-z]|\x1b[=>78M]/g;
|
||||
// PageUp / PageDown as xterm.js encodes them. Used as the LAST-RESORT scroll
|
||||
// gesture for a repaint-mode CLI whose local buffer holds no scrollback
|
||||
// gesture for a CLI whose local buffer holds no scrollback
|
||||
// (_maybePageCliTranscript).
|
||||
const KEY_PAGE_UP = '\x1b[5~';
|
||||
const KEY_PAGE_DOWN = '\x1b[6~';
|
||||
@@ -92,6 +92,20 @@
|
||||
// Bound on page keys emitted from one gesture batch, mirroring the SGR tick
|
||||
// cap: a fling must not build a backlog that keeps paging after it stops.
|
||||
const PAGE_KEY_MAX_PER_BATCH = 3;
|
||||
// A page-key scroll event after this much quiet, or in the opposite direction,
|
||||
// starts a NEW gesture, and the first event of a gesture pages at once rather
|
||||
// than waiting for half a screen of travel (a trackpad flick never got there).
|
||||
const PAGE_KEY_GESTURE_GAP_MS = 150;
|
||||
// ...but not for sub-row jitter (0.1 row, ~2px)...
|
||||
const PAGE_KEY_MIN_START_ROWS = 0.1;
|
||||
// ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px,
|
||||
// 4 rows) accumulates toward half a screen as before; paging at once on it made
|
||||
// slow notches, each its own gesture past the gap, page a full screen apiece.
|
||||
const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2;
|
||||
// A pre-paid page's debt outlives the gesture gap for this long when the next
|
||||
// event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a
|
||||
// Firefox 1-line notch) pay it back instead of each pre-paying a page of its own.
|
||||
const PAGE_KEY_DEBT_MS = 1000;
|
||||
|
||||
// Wheel delta → scroll lines (fractional), for a terminal `rows` tall. The
|
||||
// body of the primary pane's _wheelScrollLinesFloat (see its comment for the
|
||||
@@ -133,13 +147,17 @@
|
||||
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
|
||||
}
|
||||
|
||||
// Rows of travel per page key for a terminal `rows` tall (half a screen).
|
||||
function pageKeyRows(rows) {
|
||||
return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
|
||||
}
|
||||
|
||||
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
|
||||
// `lines` to the sub-page travel already `pending`, and returns the travel
|
||||
// left over plus the keys to send ('' below one page). The arithmetic of the
|
||||
// primary pane's _maybePageCliTranscript, pure so a TerminalTile (which keeps
|
||||
// its own pending travel) pages identically.
|
||||
// left over plus the keys to send ('' below one page). The plain accumulation
|
||||
// step of pageKeysForGesture below.
|
||||
function pageKeysForTravel(pending, lines, rows) {
|
||||
const perPage = Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION));
|
||||
const perPage = pageKeyRows(rows);
|
||||
const total = (pending || 0) + lines;
|
||||
const pages = Math.trunc(total / perPage);
|
||||
const keys = pages
|
||||
@@ -148,6 +166,77 @@
|
||||
return { pending: total - pages * perPage, keys };
|
||||
}
|
||||
|
||||
// One wheel or touch event of a hollow-pane gesture → the PageUp/PageDown keys
|
||||
// to send, for a terminal `rows` tall, at time `now` (ms). Pure: `state` is
|
||||
// the previous call's returned state (null or {} for a fresh pane), and the
|
||||
// caller keeps it, so the primary pane and every TerminalTile page with the
|
||||
// same gesture rules through their own state:
|
||||
// - a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel) and a mostly
|
||||
// horizontal swipe (both deltas present, |deltaX| > |deltaY|) send nothing
|
||||
// and leave the state alone (the caller still consumes them);
|
||||
// - a NEW gesture starts on the first event, on a different touch contact
|
||||
// (`ev.touchId`, set by the touch paths per touchstart and carried by that
|
||||
// contact's momentum; a wheel event has none, so wheel and touch never
|
||||
// share a gesture), or, for events outside any contact, after quiet:
|
||||
// PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid
|
||||
// page is still owed in the same direction. One contact is never split by
|
||||
// time, so a slow drag (one line per 170 ms) is one gesture;
|
||||
// - a new gesture's trackpad-sized opening event (at least
|
||||
// PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE,
|
||||
// pre-paid: the half screen it skipped is owed back by the rest of the
|
||||
// gesture, so the rate stays one page per half screen. A debt left by a
|
||||
// pre-paid page dies with its gesture;
|
||||
// - a direction change inside a gesture (a reversal within the gap, or
|
||||
// within the same contact) drops a pre-paid debt and accumulates, never
|
||||
// pre-paying, so a +/-3 px trackpad wobble cannot page back and forth;
|
||||
// - everything else, a wheel notch included, accumulates through
|
||||
// pageKeysForTravel, carried across notches.
|
||||
// Returns { state, keys } with keys '' when nothing is due.
|
||||
function pageKeysForGesture(state, ev, lines, rows, now) {
|
||||
const prev = state || {};
|
||||
if (ev?.ctrlKey) return { state: prev, keys: '' };
|
||||
const dx = Math.abs(ev?.deltaX || 0);
|
||||
const dy = Math.abs(ev?.deltaY || 0);
|
||||
if (dx && dy && dx > dy) return { state: prev, keys: '' };
|
||||
const perPage = pageKeyRows(rows);
|
||||
const dir = lines < 0 ? -1 : 1;
|
||||
const touchId = typeof ev?.touchId === 'number' ? ev.touchId : null;
|
||||
const prevTouchId = typeof prev.touchId === 'number' ? prev.touchId : null;
|
||||
let pending = prev.pending || 0;
|
||||
let prepaid = !!prev.prepaid;
|
||||
const reversed = dir !== prev.dir;
|
||||
let gestureStart;
|
||||
if (typeof prev.lastAt !== 'number' || touchId !== prevTouchId) {
|
||||
gestureStart = true;
|
||||
} else if (touchId !== null) {
|
||||
gestureStart = false; // the same contact, however long it has been
|
||||
} else {
|
||||
const quiet = now - prev.lastAt;
|
||||
gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS);
|
||||
}
|
||||
if (gestureStart || reversed) {
|
||||
// A pre-paid debt belongs to its gesture and its direction.
|
||||
if (prepaid) pending = 0;
|
||||
prepaid = false;
|
||||
}
|
||||
if (gestureStart) {
|
||||
const size = Math.abs(lines);
|
||||
if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) {
|
||||
// Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign).
|
||||
return {
|
||||
state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId },
|
||||
keys: dir < 0 ? KEY_PAGE_UP : KEY_PAGE_DOWN,
|
||||
};
|
||||
}
|
||||
}
|
||||
const step = pageKeysForTravel(pending, lines, rows);
|
||||
// A debt carries the sign opposite to its direction; once the travel reaches
|
||||
// or crosses zero it is repaid, and what is left is plain travel (so the
|
||||
// one-second debt lifetime no longer applies).
|
||||
if (prepaid && step.pending * dir >= 0) prepaid = false;
|
||||
return { state: { pending: step.pending, lastAt: now, dir, prepaid, touchId }, keys: step.keys };
|
||||
}
|
||||
|
||||
const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4;
|
||||
// Composer navigation keys as xterm.js encodes user keystrokes: plain and
|
||||
// modified arrows (CSI A-D, CSI 1;mA-D, SS3 A-D), Home/End (CSI H/F, SS3
|
||||
@@ -303,6 +392,11 @@
|
||||
SGR_WHEEL_MAX_TICKS,
|
||||
sgrWheelReports,
|
||||
pageKeysForTravel,
|
||||
pageKeysForGesture,
|
||||
PAGE_KEY_GESTURE_GAP_MS,
|
||||
PAGE_KEY_MIN_START_ROWS,
|
||||
PAGE_KEY_IMMEDIATE_MAX_ROWS,
|
||||
PAGE_KEY_DEBT_MS,
|
||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
||||
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
|
||||
@@ -612,6 +706,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
this._installOsc52Clipboard(this.terminal);
|
||||
|
||||
const container = document.getElementById('terminalContainer');
|
||||
this.terminal.open(container);
|
||||
this._initMobileImePreview();
|
||||
@@ -1056,6 +1152,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
let lastTime = 0;
|
||||
let scrollFrame = null;
|
||||
let isTouching = false;
|
||||
// One id per touch contact (touchstart), carried by its touchmoves AND its
|
||||
// momentum frames, so the page-key fallback treats a whole contact as one
|
||||
// gesture however slowly the finger moves (pageKeysForGesture).
|
||||
let touchContactId = 0;
|
||||
|
||||
const scrollLoop = (timestamp) => {
|
||||
const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1;
|
||||
@@ -1069,7 +1169,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Flick momentum keeps feeding the CLI's transcript from the last
|
||||
// touch point; the 40ms coalescer batches the per-frame reports.
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
|
||||
this.terminal.scrollLines(lines);
|
||||
this._maybeLoadMoreHistoryOnScroll(lines);
|
||||
}
|
||||
@@ -1142,6 +1242,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
touchContactId++;
|
||||
didScroll = false;
|
||||
tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused();
|
||||
// Classifying scans the whole viewport with translateToString, and
|
||||
@@ -1246,7 +1347,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._shouldForwardWheelToApp({ shiftKey: false })) {
|
||||
this._logScrollRouting('forward-sgr');
|
||||
this._forwardScrollToApp(touchLastX, touchLastY, lines);
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false }, lines)) {
|
||||
} else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) {
|
||||
this._logScrollRouting('local-scrollback');
|
||||
this._noteTerminalUserScroll(lines);
|
||||
this.terminal.scrollLines(lines);
|
||||
@@ -1918,6 +2019,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* A clicked path as the preview needs it: relative paths resolved against the
|
||||
* session's working directory, absolute and `~/` paths unchanged
|
||||
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
|
||||
* provider and the response viewer, so one path opens the same file in both.
|
||||
*/
|
||||
_resolveLinkedFilePath(filePath, sessionId) {
|
||||
if (typeof resolveLinkedFilePath !== 'function') return filePath;
|
||||
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a custom link provider for xterm.js that detects file paths
|
||||
* in terminal output and makes them clickable.
|
||||
@@ -1970,10 +2082,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// both continuation kinds, the indent a hard wrap leaves on its
|
||||
// continuation, and the offset↔cell mapping — because touch selection
|
||||
// measures the SAME lines and the two must not disagree.
|
||||
// `terminalPathLine` adds one thing on top, for links only: a path the
|
||||
// program broke over lines itself (Claude Code wraps tool output short of
|
||||
// the edge) is glued back together, so `…/captures/2` + `026-10-10/x.png`
|
||||
// links as the one file it is.
|
||||
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
||||
// cannot make every hover stitch and re-scan the entire viewport.
|
||||
const MAX_STITCHED_ROWS = 12;
|
||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||
const logical = window.CodemanTerminalLines?.terminalPathLine(
|
||||
buffer,
|
||||
bufferLineNumber - 1,
|
||||
terminal.cols,
|
||||
@@ -1991,12 +2107,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { x: cell.col + 1, y: cell.row + 1 };
|
||||
};
|
||||
|
||||
if (!lineText || !lineText.includes('/')) {
|
||||
// A path needs a `/` or an extension's `.`; a line with neither has no link.
|
||||
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const links = [];
|
||||
// Text offsets of every link so far: a later pattern must not cut a
|
||||
// second, overlapping link out of one already found (a relative `b.png`
|
||||
// inside a URL's `?img=b.png`).
|
||||
const taken = [];
|
||||
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
//
|
||||
@@ -2016,6 +2138,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + cleaned.length);
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
taken.push({ start: startCol, end: startCol + cleaned.length });
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
@@ -2045,27 +2168,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
|
||||
// Pattern 2: Paths with common extensions — absolute, `~/` and relative
|
||||
// (`findFilePathLinks`, constants.js). Image/PDF/media extensions are
|
||||
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
|
||||
// screenshots an agent just wrote are clickable; those open the file
|
||||
// preview rather than the log viewer (see addLink).
|
||||
//
|
||||
// The literal lives in constants.js because the response viewer linkifies
|
||||
// the SAME paths out of markdown — one definition, two consumers. A fresh
|
||||
// instance per call: `lastIndex` is per-object state.
|
||||
const extPattern = absoluteFilePathPattern();
|
||||
// The patterns live in constants.js because the response viewer linkifies
|
||||
// the SAME paths out of markdown — one definition, two consumers.
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
const addLink = (filePath, matchIndex) => {
|
||||
const addLink = (filePath, matchIndex, folder = false) => {
|
||||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + filePath.length);
|
||||
// Skip if already have link at this position
|
||||
// Skip if already have link at this position, or one overlapping it
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
if (isTaken(startCol, filePath.length)) return;
|
||||
taken.push({ start: startCol, end: startCol + filePath.length });
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
@@ -2088,12 +2212,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
// path clicked in the response viewer previewed fine. The preview
|
||||
// reads those through the guarded attachment routes, so external
|
||||
// paths route there and the two surfaces agree.
|
||||
//
|
||||
// A relative path is resolved against the session's working
|
||||
// directory first, and a `~/` path always previews (only the
|
||||
// server knows that host's home, and the log viewer cannot ask).
|
||||
const sessionId = getSessionId();
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||
self.openFilePreview(text, sessionId);
|
||||
const target = self._resolveLinkedFilePath(text, sessionId);
|
||||
// A folder path opens the folder grid (which falls back to the
|
||||
// preview if the server finds a file there after all).
|
||||
if (folder) {
|
||||
self.openFolderGrid(target, sessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, sessionId);
|
||||
// An HTML file renders as a page in the preview, never as source
|
||||
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
|
||||
if (
|
||||
previewsInFileViewer(target) ||
|
||||
rendersAsPage(target) ||
|
||||
self._isExternalPreviewPath(target, sessionId)
|
||||
) {
|
||||
self.openFilePreview(target, sessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(target, sessionId);
|
||||
},
|
||||
hover() {
|
||||
setHovered(true);
|
||||
@@ -2117,9 +2258,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
addLink(match[2], match.index);
|
||||
}
|
||||
|
||||
extPattern.lastIndex = 0;
|
||||
while ((match = extPattern.exec(lineText)) !== null) {
|
||||
addLink(match[1], match.index);
|
||||
for (const found of findFilePathLinks(lineText)) {
|
||||
addLink(found.path, found.index, !!found.folder);
|
||||
}
|
||||
|
||||
bashPattern.lastIndex = 0;
|
||||
@@ -3669,10 +3809,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
|
||||
* the resulting position, and it is deliberately not folded into
|
||||
* _noteTerminalUserScroll for exactly that reason.
|
||||
*
|
||||
* It is also what brings up the partial-history notice, and what retires it
|
||||
* once a downward scroll is back at live output (_setHistoryNoticeRevealed).
|
||||
* The reveal waits for the pull this gesture started, so the notice describes
|
||||
* what the pull left rather than flashing the state it is about to replace.
|
||||
*/
|
||||
_maybeLoadMoreHistoryOnScroll(lines) {
|
||||
if (lines >= 0) return;
|
||||
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
|
||||
if (lines > 0) {
|
||||
if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null);
|
||||
return;
|
||||
}
|
||||
if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return;
|
||||
// An earlier frame of this gesture already started the pull; its own
|
||||
// completion reveals the notice, so do not flash the pre-pull state now.
|
||||
if (this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||
const sessionId = this.activeSessionId;
|
||||
Promise.resolve(this._maybeRefetchFullHistory?.())
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4456,8 +4613,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Strip any DEC 2026 markers that might be in the buffer
|
||||
// (from historical SSE data that was stored with markers)
|
||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||
// (from historical SSE data that was stored with markers), and every
|
||||
// OSC 52 clipboard write: a replay must never copy (see OSC52_STRIP_RE).
|
||||
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '').replace(OSC52_STRIP_RE, '');
|
||||
|
||||
// For small buffers, write directly — single-frame render is fast enough
|
||||
if (cleanBuffer.length <= chunkSize) {
|
||||
@@ -4923,6 +5081,51 @@ Object.assign(CodemanApp.prototype, {
|
||||
return Number.isInteger(columns) && columns > 0 ? columns : 0;
|
||||
},
|
||||
|
||||
// OSC 52 (`ESC ] 52 ; <sel> ; <base64> BEL`) is how programs inside the PTY —
|
||||
// Claude Code's /copy, tmux yanks, vim — set the clipboard. The server has no
|
||||
// display, so this is the only route to the browser's clipboard. Writes only:
|
||||
// a `?` query (clipboard read) is ignored. A replay must never copy: the main
|
||||
// terminal's replays strip OSC 52 in chunkedTerminalWrite (xterm parses after
|
||||
// _isLoadingBuffer is already cleared, so that flag alone cannot hold), and a
|
||||
// TerminalTile passes `isReplaying`, its own load flag, which does hold there.
|
||||
// Every tab watching the session gets the sequence, so only the focused one
|
||||
// opens the manual-copy dialog when the browser refuses the write.
|
||||
// One copy usually arrives several times: Claude Code writes OSC 52 itself and
|
||||
// through `tmux load-buffer -w`, and tmux forwards it with set-clipboard on,
|
||||
// so the same text lands up to three times within a few ms. Identical text
|
||||
// inside OSC52_DEDUPE_MS is that one copy, not a new one.
|
||||
_installOsc52Clipboard(terminal, isReplaying = () => this._isLoadingBuffer) {
|
||||
terminal.parser?.registerOscHandler?.(52, (data) => {
|
||||
const sep = data.indexOf(';');
|
||||
const payload = sep >= 0 ? data.slice(sep + 1) : data;
|
||||
if (!payload || payload === '?' || isReplaying()) return true;
|
||||
let text;
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
|
||||
text = new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (!text) return true;
|
||||
const now = Date.now();
|
||||
if (this._lastOsc52?.text === text && now - this._lastOsc52.at < OSC52_DEDUPE_MS) return true;
|
||||
this._lastOsc52 = { text, at: now };
|
||||
// _copyText's execCommand fallback (plain HTTP, or a refused Clipboard API
|
||||
// write) focuses a temporary textarea; hand focus back as Auto Copy does.
|
||||
const focusedBefore = document.activeElement;
|
||||
this._copyText(text).then((ok) => {
|
||||
if (focusedBefore && focusedBefore !== document.activeElement && focusedBefore.isConnected) {
|
||||
try {
|
||||
focusedBefore.focus();
|
||||
} catch {}
|
||||
}
|
||||
if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
|
||||
else if (document.hasFocus?.()) this._showClipboardFallback?.(text);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||
@@ -5547,7 +5750,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// pane; either of the first two left out means the primary pane's.
|
||||
// `ephemeral: true` sends it through _sendInputEphemeral instead of the
|
||||
// persisted exactly-once queue: a TerminalTile's mouse reports never enter
|
||||
// that queue (CLAUDE.md, Split-pane sessions), or a reload would replay one
|
||||
// that queue (src/web/CLAUDE.md, Split-pane sessions), or a reload would replay one
|
||||
// onto a later screen. Left out, the report stays on _sendInputAsync, as the
|
||||
// primary pane always sent it.
|
||||
_sendSyntheticSgrTap(clientX, clientY, target = {}) {
|
||||
@@ -5713,35 +5916,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* True when this session's LOCAL scrollback is structurally empty: a pane whose
|
||||
* TUI repaints one full screen in place, so tmux keeps no history for it
|
||||
* (`history_size≈0`) and xterm's normal buffer never grows past one screen
|
||||
* (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture
|
||||
* is routed — the "wheel does nothing at all" half of the #205 retest.
|
||||
* True when this session's LOCAL scrollback is structurally empty and its CLI
|
||||
* can page its own transcript: xterm's normal buffer never grows past one
|
||||
* screen (`baseY === 0`), so scrolling it is a no-op no matter how the gesture
|
||||
* is routed (the "wheel does nothing at all" half of the #205 retest).
|
||||
*
|
||||
* Two shapes, measured separately:
|
||||
* - `claude` in repaint mode (the original, #205 round 2), and
|
||||
* Shapes measured so far:
|
||||
* - `claude` in repaint mode (the original, #205 round 2): tmux reports
|
||||
* `history_size≈0` and every frame overwrites the last;
|
||||
* - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux
|
||||
* `alternate_on=1`, `history_size=0`) and so pushes nothing into the
|
||||
* terminal's scrollback at all. It pages its own transcript with the same
|
||||
* PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel
|
||||
* reports — six `\x1b[<64;…M` reports against an idle pane left the capture
|
||||
* byte-identical — so paging is the only gesture that reaches it. Without
|
||||
* this the wheel was silently dead in every opencode tab.
|
||||
* byte-identical — so paging is the only gesture that reaches it;
|
||||
* - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which
|
||||
* the full strip hides from xterm, and pages on PageUp/PageDown while SGR
|
||||
* wheel reports do nothing.
|
||||
*
|
||||
* Every other mode is deliberately absent: shell/pi own real terminal
|
||||
* scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour
|
||||
* is unverified (docs/scrollback-fix-plan.md).
|
||||
* ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the
|
||||
* list the server builds from the `transcriptPageKeys` CAPABILITY, never an id
|
||||
* literal here. A missing list means no mode pages.
|
||||
*
|
||||
* `target` ({ terminal, sessionId, localRows }) asks for a TerminalTile, which
|
||||
* calls this with its own session and terminal, so the mode list above stays
|
||||
* here alone. `localRows` replaces `baseY` as the history row count: a tile
|
||||
* calls this with its own session and terminal, so the mode rule stays here
|
||||
* alone. `localRows` replaces `baseY` as the history row count: a tile
|
||||
* discounts the stale rows its own load order leaves above the screen
|
||||
* (TerminalTile._localRows). Every field left out means the primary pane's.
|
||||
*/
|
||||
_localScrollbackIsHollow(target = {}) {
|
||||
const mode = this.sessions?.get(target.sessionId || this.activeSessionId)?.mode || 'claude';
|
||||
if (mode !== 'claude' && mode !== 'opencode') return false;
|
||||
const pagingModes = window.__codemanTranscriptPageKeys;
|
||||
if (!Array.isArray(pagingModes) || !pagingModes.includes(mode)) return false;
|
||||
const buf = (target.terminal || this.terminal)?.buffer?.active;
|
||||
if (!buf || buf.type === 'alternate') return false;
|
||||
const rows = Number.isFinite(target.localRows) ? target.localRows : buf.baseY;
|
||||
@@ -5758,34 +5964,71 @@ Object.assign(CodemanApp.prototype, {
|
||||
* unset (the inline renderer, or fullscreen right after a server restart), the
|
||||
* user turned on "Wheel scrolls local history" (which pins the wheel to a buffer
|
||||
* that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is
|
||||
* opencode, which never fills the buffer and never accepts the wheel. Before
|
||||
* one that never fills the buffer and never accepts the wheel (opencode, codex:
|
||||
* both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before
|
||||
* this, all of those produced a completely dead gesture; the #205 reporter
|
||||
* proved the keyboard route works by paging back through intact text with Fn+Up.
|
||||
* This is not wheel forwarding: it sends plain keys, and only over a hollow buffer.
|
||||
*
|
||||
* Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a
|
||||
* session with real local scrollback is never touched. Shift is excluded on
|
||||
* purpose: it is the explicit "give me local scrollback" gesture and must keep
|
||||
* that meaning.
|
||||
* Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
|
||||
* `baseY === 0`) plus a false forwarding gate, so a session with real local
|
||||
* scrollback is never touched. Shift is excluded on purpose: it is the explicit
|
||||
* "give me local scrollback" gesture and must keep that meaning.
|
||||
*
|
||||
* The FIRST event of a trackpad-sized gesture (opening under
|
||||
* PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of
|
||||
* travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick
|
||||
* never sent a key at all. That page is pre-paid: the travel it skipped is
|
||||
* owed back by the rest of the gesture, so the rate stays one page per
|
||||
* `perPage` rows. A new touch contact (`ev.touchId`), a pause outside a contact
|
||||
* (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is
|
||||
* still owed), a reversal after the gap or a tab switch starts a new gesture;
|
||||
* one contact is never split by time, and a quick reversal never pre-pays. A
|
||||
* larger opening event is a wheel notch and accumulates as before, so slow
|
||||
* notches still page once per `perPage` rows.
|
||||
*
|
||||
* Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad
|
||||
* pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|;
|
||||
* the touch path locks its own axis before it gets here), and any gesture while
|
||||
* the session has an open dialog (tab alert 'action', set by a pending
|
||||
* permission_prompt or elicitation_dialog), so a page key never reaches its
|
||||
* selector.
|
||||
*
|
||||
* @returns true when the gesture was consumed here (the caller must not also
|
||||
* scroll locally).
|
||||
*
|
||||
* The gesture rules above live in the pure CodemanTerminalInput.pageKeysForGesture
|
||||
* (top of this file); this method adds the gates and keeps the state.
|
||||
*
|
||||
* Twin: TerminalTile._maybePageCliTranscript (terminal-tile.js) pages a tile
|
||||
* through the same gates and the same pageKeysForTravel arithmetic; keep the
|
||||
* two in step. The tile adds one gate this pane cannot need, viewport at the
|
||||
* bottom: hollow here means baseY 0, so this viewport is always there, while a
|
||||
* tile is hollow with its own discounted rows still above the screen.
|
||||
* through the same gates and the same pageKeysForGesture, with its own state
|
||||
* and its OWN session's dialog check; keep the two in step. The tile adds one
|
||||
* gate this pane cannot need, viewport at the bottom: hollow here means baseY 0,
|
||||
* so this viewport is always there, while a tile is hollow with its own
|
||||
* discounted rows still above the screen.
|
||||
*/
|
||||
_maybePageCliTranscript(ev, lines) {
|
||||
if (!lines || ev?.shiftKey || !this.activeSessionId) return false;
|
||||
if (!this._localScrollbackIsHollow()) return false;
|
||||
// Leftover travel belongs to the tab it was made on.
|
||||
// An open dialog in THIS pane's session (the active one): a page key would
|
||||
// move its selector. Consumed, so the buffer does not scroll under it either.
|
||||
if (this.tabAlerts?.get(this.activeSessionId) === 'action') {
|
||||
this._logScrollRouting('page-keys-dialog-open');
|
||||
return true;
|
||||
}
|
||||
// Leftover travel and the gesture in progress belong to the tab they were made on.
|
||||
if (this._pageKeySession !== this.activeSessionId) {
|
||||
this._pageKeySession = this.activeSessionId;
|
||||
this._pageKeyPending = 0;
|
||||
this._pageKeyGesture = null;
|
||||
}
|
||||
const step = window.CodemanTerminalInput.pageKeysForTravel(this._pageKeyPending, lines, this.terminal?.rows);
|
||||
this._pageKeyPending = step.pending;
|
||||
const step = window.CodemanTerminalInput.pageKeysForGesture(
|
||||
this._pageKeyGesture,
|
||||
ev,
|
||||
lines,
|
||||
this.terminal?.rows,
|
||||
performance.now()
|
||||
);
|
||||
this._pageKeyGesture = step.state;
|
||||
if (step.keys) this._queueScrollBytes(step.keys);
|
||||
this._logScrollRouting('page-keys');
|
||||
return true;
|
||||
|
||||
@@ -56,6 +56,26 @@ const TILE_GRID_REFIT_MS = 150;
|
||||
// the grid section's padding (styles.css .tile-grid), for the drag math.
|
||||
const TILE_DIVIDER_PX = 6;
|
||||
const TILE_GRID_PADDING_PX = 4;
|
||||
// What a tile's header drag (and a popped-out window's title drag) carries:
|
||||
// the session id under a type of its own, never text, so no text field or
|
||||
// terminal takes it as typing. Another Codeman window reads it on drop.
|
||||
const TILE_DRAG_TYPE = 'application/x-codeman-tile';
|
||||
// Detach Tiles: a tile dropped outside every window waits this long before it
|
||||
// opens as a window of its own, so another window that took it (a
|
||||
// 'tile-adopted' message on the window channel) can still say so first.
|
||||
const TILE_TEAR_OFF_SETTLE_MS = 120;
|
||||
// How long after its drag ended a tile can still be claimed by another
|
||||
// window's drop (the claim and the dragend come from two windows).
|
||||
const TILE_ADOPT_WINDOW_MS = 5000;
|
||||
// A session docked here from another window: its old window's 'detached'
|
||||
// announcements this soon after are stale (it is closing), not a new pop-out.
|
||||
const TILE_DOCK_GRACE_MS = 2000;
|
||||
// The smallest window a detached tile opens as, and roughly how much browser
|
||||
// chrome sits above a pop-out's content (its title and address bars), so the
|
||||
// window's own header lands near where the tile was let go.
|
||||
const TILE_WINDOW_MIN_W = 480;
|
||||
const TILE_WINDOW_MIN_H = 320;
|
||||
const TILE_WINDOW_CHROME_PX = 60;
|
||||
|
||||
/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
|
||||
function tileGridTracks(fr) {
|
||||
@@ -324,12 +344,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionId = tileAt(e.target);
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (!sessionId || files.length === 0) return;
|
||||
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
|
||||
if (images.length === 0) {
|
||||
this.showToast?.('Only image files are supported', 'error');
|
||||
// The one classifier every pick and drop filter reads (image-input.js):
|
||||
// MIME first, then the name's extension, so documents and videos count too.
|
||||
const media = files.filter((f) => this._promptAttachKind?.(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast?.('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(images, { sessionId });
|
||||
this._uploadAndInsertImages?.(media, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1552,8 +1574,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `accepts(id)` is the target's own rule (a tile takes any session but its
|
||||
* own; an empty cell takes any). A session it does not accept
|
||||
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
|
||||
* drop follows). Any other drag (a file) is left to the grid section's own
|
||||
* guard (_installTileFileDrop).
|
||||
* drop follows). With Detach Tiles on, a tile dragged from ANOTHER Codeman
|
||||
* window (no drag of this page's own, the tile type in the drag) is taken
|
||||
* too and docks here (_dockForeignTile). Any other drag (a file) is left to
|
||||
* the grid section's own guard (_installTileFileDrop).
|
||||
*/
|
||||
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
|
||||
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
|
||||
@@ -1561,7 +1585,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
'dragover',
|
||||
(e) => {
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
if (!id) {
|
||||
// A tile from another Codeman window (Detach Tiles). Its id can
|
||||
// only be read on drop, so it is held here whatever it is.
|
||||
if (!this._isForeignTileDrag(e)) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
el.classList.add('tile--drop-target');
|
||||
return;
|
||||
}
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
const ok = accepts(id);
|
||||
@@ -1578,7 +1611,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
(e) => {
|
||||
el.classList.remove('tile--drop-target');
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
if (!id) {
|
||||
if (!this._isForeignTileDrag(e)) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
this._dockForeignTile(e.dataTransfer?.getData?.(TILE_DRAG_TYPE), onDrop);
|
||||
return;
|
||||
}
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (accepts(id)) onDrop(id);
|
||||
@@ -2080,7 +2119,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* input starts no drag. dragstart's target is the header whatever was
|
||||
* pressed, so where the press landed is noted in the capture phase, ahead of
|
||||
* the buttons' own stopPropagation. `draggable` is off while moving is
|
||||
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
|
||||
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress),
|
||||
* unless Detach Tiles is on: then a drop outside the window pops the tile
|
||||
* out, and a drop on another Codeman window's tiles moves it there (see the
|
||||
* Detach Tiles section); a rename still turns it off.
|
||||
*/
|
||||
_installTileMoveDrag(sessionId, header, actions) {
|
||||
let pressedOnControl = false;
|
||||
@@ -2094,7 +2136,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
header.addEventListener('dragstart', (e) => {
|
||||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||||
if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
|
||||
// Detach Tiles lets a tile that cannot move (alone, or zoomed) still be
|
||||
// dragged, out of the window; no drop target here takes it then.
|
||||
const detachable = this._tileDetachAllowed();
|
||||
if (!entry || pressedOnControl || entry.renaming || !(this._tilesMovable() || detachable)) {
|
||||
e.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
@@ -2102,11 +2147,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
entry.el.classList.add('tile--dragging');
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox starts no drag without data.
|
||||
e.dataTransfer.setData('application/x-codeman-tile', sessionId);
|
||||
// Firefox starts no drag without data. Another Codeman window reads
|
||||
// the id from it (Detach Tiles).
|
||||
e.dataTransfer.setData(TILE_DRAG_TYPE, sessionId);
|
||||
}
|
||||
if (detachable) this._beginTileTearOff(sessionId, entry, e);
|
||||
});
|
||||
header.addEventListener('dragend', (e) => {
|
||||
this._settleTileTearOff(sessionId, e);
|
||||
this._endTileMoveDrag();
|
||||
});
|
||||
header.addEventListener('dragend', () => this._endTileMoveDrag());
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2117,6 +2167,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
_endTileMoveDrag() {
|
||||
this._draggedTileId = null;
|
||||
this._stopTileTearOffTracking();
|
||||
const grid = this._tileGrid;
|
||||
for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
|
||||
for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
|
||||
@@ -2124,19 +2175,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* The header as a handle: `draggable` while the tile can move (not while a
|
||||
* tile is zoomed, alone, or being renamed), and its tooltip, the state and
|
||||
* how long ("working 3m") plus, while it can move, that it drags. Diffs on
|
||||
* the last English text, never the DOM (translated in zh-CN; see
|
||||
* tile is zoomed, alone, or being renamed) or, with Detach Tiles on, be
|
||||
* dragged out of the window (then also alone or zoomed), and its tooltip,
|
||||
* the state and how long ("working 3m") plus what a drag does. Diffs on the
|
||||
* last English text, never the DOM (translated in zh-CN; see
|
||||
* _renderTileOverlay).
|
||||
*/
|
||||
_paintTileHandle(entry) {
|
||||
const movable = this._tilesMovable();
|
||||
const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
|
||||
const detachable = this._tileDetachAllowed();
|
||||
let hint = '';
|
||||
if (movable && detachable) hint = 'Drag to move the tile, or out of the window to open it on its own';
|
||||
else if (movable) hint = 'Drag to move the tile';
|
||||
else if (detachable) hint = 'Drag out of the window to open the tile on its own';
|
||||
const title = [entry.stateLabel, hint].filter(Boolean).join('\n');
|
||||
if (entry.headerLabel !== title) {
|
||||
entry.headerLabel = title;
|
||||
entry.header.title = title;
|
||||
}
|
||||
const draggable = movable && !entry.renaming ? 'true' : 'false';
|
||||
const draggable = (movable || detachable) && !entry.renaming ? 'true' : 'false';
|
||||
if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
|
||||
},
|
||||
|
||||
@@ -2859,6 +2916,370 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ── Detach Tiles (per-device `tileDetachEnabled`, default OFF) ───────────
|
||||
//
|
||||
// A tile leaves the grid for a window of its own, and comes back into any
|
||||
// Codeman window's grid, by dragging:
|
||||
//
|
||||
// - its header dropped OUTSIDE every window opens it as a pop-out window
|
||||
// (detachSession, the tab's own "Open in a new window"), at the drop point
|
||||
// and the tile's size;
|
||||
// - its header dropped on ANOTHER Codeman window's tile or empty cell moves
|
||||
// it there: that window docks it and says so on the window channel
|
||||
// ('tile-adopted'), and this one lets the tile go;
|
||||
// - a pop-out window's title dropped on a window's tile or empty cell docks
|
||||
// the session there, and the pop-out closes.
|
||||
//
|
||||
// Nothing new on the server: a pop-out is another client of the same session
|
||||
// (see detachSession), and a tile is a TerminalTile in whichever window has it.
|
||||
// A session stays in ONE place in this browser, as with every pop-out.
|
||||
|
||||
/** Whether Detach Tiles is on for this device. */
|
||||
tileDetachEnabled() {
|
||||
return this.loadAppSettingsFromStorage?.()?.tileDetachEnabled === true;
|
||||
},
|
||||
|
||||
/** Detach Tiles applies here: on, and a dashboard (a pop-out has no grid to drag from). */
|
||||
_tileDetachAllowed() {
|
||||
return !this.isSoloWindow && this.tileDetachEnabled();
|
||||
},
|
||||
|
||||
/**
|
||||
* A drag over this page that is a tile from another Codeman window: none of
|
||||
* this page's own drags is in flight, Detach Tiles is on and the drag
|
||||
* carries the tile type (its id is only readable on drop).
|
||||
*/
|
||||
_isForeignTileDrag(e) {
|
||||
if (this.draggedTabId || this._draggedTileId || !this._tileGrid?.open || !this._tileDetachAllowed()) return false;
|
||||
const types = e?.dataTransfer?.types;
|
||||
return !!types && Array.from(types).includes(TILE_DRAG_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* A tile from another window dropped on one of this grid's targets: it docks
|
||||
* through the target's own drop (`place`: a tile it replaces or trades
|
||||
* places with, an empty cell it fills), and takes focus there (a human
|
||||
* selection: the user just put it there). A session that was popped out comes
|
||||
* back first (its window is asked to close). Once it is a tile here, every
|
||||
* other window hears 'tile-adopted', and the one it was dragged from lets
|
||||
* its tile go (_onTileAdoptedElsewhere). An id this page does not know (a
|
||||
* session closed meanwhile, another user's) docks nowhere.
|
||||
*
|
||||
* @returns {boolean} whether the session is a tile here afterwards
|
||||
*/
|
||||
_dockForeignTile(sessionId, place) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || typeof sessionId !== 'string' || !this.sessions.has(sessionId)) return false;
|
||||
const poppedOut = !!this.detachedSessions?.has(sessionId);
|
||||
// A pop-out window owns its session's place: take it back before mounting
|
||||
// (a detached session never mounts).
|
||||
if (poppedOut) this._redock?.(sessionId);
|
||||
// Already a tile here too (two windows showed it): it moves to where it
|
||||
// was dropped, as a drag inside this window would move it.
|
||||
place(sessionId);
|
||||
if (!grid.has(sessionId)) return false;
|
||||
(this._tileDockedAt ||= new Map()).set(sessionId, Date.now());
|
||||
// Asked unconditionally: a pop-out this window never heard of (opened from
|
||||
// another dashboard) closes too. Dashboards ignore it; only that id's
|
||||
// pop-out reacts.
|
||||
this._postWindowMessage?.({ type: 'close-request', id: sessionId });
|
||||
this._postWindowMessage?.({ type: 'tile-adopted', id: sessionId, by: this._wsTabNonce });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* True for a session this window docked from another one a moment ago: a
|
||||
* 'detached' announcement from its old pop-out is that window on its way
|
||||
* out, never a new pop-out (it would take the tile straight back off).
|
||||
*/
|
||||
_tileDockedRecently(sessionId) {
|
||||
const at = this._tileDockedAt?.get(sessionId);
|
||||
return typeof at === 'number' && Date.now() - at < TILE_DOCK_GRACE_MS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Another window docked `sessionId` ('tile-adopted'). Only the window it
|
||||
* was dragged FROM acts: its drag is still settling or ended a moment ago.
|
||||
* The tile goes without taking the keyboard anywhere (the user is in the
|
||||
* other window now). The last tile leaving closes the grid onto the welcome
|
||||
* screen, never onto that session: two windows sizing one PTY garble it.
|
||||
*/
|
||||
_onTileAdoptedElsewhere(sessionId) {
|
||||
const recent = this._lastTileDrag;
|
||||
// A header drag counts even without a tear-off (Detach Tiles off here): the
|
||||
// other window can take the tile before this drag ends.
|
||||
const dragging = this._tileTearOff?.id === sessionId || this._draggedTileId === sessionId;
|
||||
const pending = this._tileTearOffPending?.id === sessionId;
|
||||
const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS;
|
||||
if (!dragging && !pending && !justEnded) return false;
|
||||
this._cancelTileTearOff();
|
||||
this._lastTileDrag = null;
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.has(sessionId)) return false;
|
||||
// One stored grid per browser, and the window that docked the tile has
|
||||
// just written it: this window losing the tile is no change of the
|
||||
// user's to remember over that one (nor to forget it).
|
||||
this._tilePersistHold = true;
|
||||
try {
|
||||
if (grid.ids.length > 1) return this.removeTile(sessionId, { focus: false });
|
||||
this.closeTileGrid({ keepStored: true, reselect: false });
|
||||
} finally {
|
||||
this._tilePersistHold = false;
|
||||
}
|
||||
this.activeSessionId = null;
|
||||
try {
|
||||
localStorage.removeItem('codeman-active-session');
|
||||
} catch {
|
||||
/* Nothing stored. */
|
||||
}
|
||||
this.terminal?.clear();
|
||||
this.showWelcome?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* A header drag starts with Detach Tiles on: note the tile's size and where
|
||||
* in it the press landed (the pop-out opens that size, under the pointer),
|
||||
* and watch whether the pointer leaves the page. Two signals, since
|
||||
* browsers differ in what dragend reports for a drop outside the window:
|
||||
* its coordinates, and the page's own dragleave at the viewport's edge
|
||||
* with nothing entered (cleared again by any dragover inside).
|
||||
*/
|
||||
_beginTileTearOff(sessionId, entry, e) {
|
||||
this._stopTileTearOffTracking();
|
||||
const rect = entry.el.getBoundingClientRect();
|
||||
const within = (v, max) => (Number.isFinite(v) ? Math.max(0, Math.min(v, max)) : 0);
|
||||
const drag = {
|
||||
id: sessionId,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
offsetX: within(e?.clientX - rect.left, rect.width),
|
||||
offsetY: within(e?.clientY - rect.top, rect.height),
|
||||
outside: false,
|
||||
stop: null,
|
||||
};
|
||||
const onOver = () => {
|
||||
drag.outside = false;
|
||||
};
|
||||
const onLeave = (ev) => {
|
||||
if (ev.relatedTarget) return;
|
||||
const x = ev.clientX;
|
||||
const y = ev.clientY;
|
||||
if (x <= 0 || y <= 0 || x >= window.innerWidth - 1 || y >= window.innerHeight - 1) drag.outside = true;
|
||||
};
|
||||
document.addEventListener('dragover', onOver, true);
|
||||
document.addEventListener('dragleave', onLeave, true);
|
||||
drag.stop = () => {
|
||||
document.removeEventListener('dragover', onOver, true);
|
||||
document.removeEventListener('dragleave', onLeave, true);
|
||||
};
|
||||
this._tileTearOff = drag;
|
||||
},
|
||||
|
||||
/** The header drag is over (or the grid or tile went mid-drag): stop watching the pointer. */
|
||||
_stopTileTearOffTracking() {
|
||||
const drag = this._tileTearOff;
|
||||
if (!drag) return;
|
||||
this._tileTearOff = null;
|
||||
drag.stop?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* dragend of a tile's header with Detach Tiles on. Taken by a drop target
|
||||
* (here or in another window: `dropEffect` is not 'none'), it stays where
|
||||
* that put it. Let go OUTSIDE the window (the dragend point, or the page's
|
||||
* own record of the pointer leaving), it opens as a window of its own after
|
||||
* TILE_TEAR_OFF_SETTLE_MS, unless another window claims it first. Let go
|
||||
* inside, on nothing, nothing happens, as before.
|
||||
*/
|
||||
_settleTileTearOff(sessionId, e) {
|
||||
const drag = this._tileTearOff?.id === sessionId ? this._tileTearOff : null;
|
||||
this._lastTileDrag = { id: sessionId, at: Date.now() };
|
||||
if (!drag) return false;
|
||||
this._stopTileTearOffTracking();
|
||||
const effect = e?.dataTransfer?.dropEffect;
|
||||
if (effect && effect !== 'none') return false;
|
||||
const cx = e?.clientX;
|
||||
const cy = e?.clientY;
|
||||
// (0, 0) is what a browser reports when it does not know where the drop was.
|
||||
const known = Number.isFinite(cx) && Number.isFinite(cy) && !(cx === 0 && cy === 0);
|
||||
const outside = known ? cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight : drag.outside;
|
||||
if (!outside) return false;
|
||||
const sx = e?.screenX;
|
||||
const sy = e?.screenY;
|
||||
const placed = Number.isFinite(sx) && Number.isFinite(sy) && !(sx === 0 && sy === 0);
|
||||
const placement = {
|
||||
width: drag.width,
|
||||
height: drag.height,
|
||||
...(placed ? { screenX: sx, screenY: sy, offsetX: drag.offsetX, offsetY: drag.offsetY } : {}),
|
||||
};
|
||||
this._cancelTileTearOff();
|
||||
const pending = { id: sessionId, timer: null };
|
||||
pending.timer = setTimeout(() => {
|
||||
if (this._tileTearOffPending !== pending) return;
|
||||
this._tileTearOffPending = null;
|
||||
this.detachTile(sessionId, placement);
|
||||
}, TILE_TEAR_OFF_SETTLE_MS);
|
||||
this._tileTearOffPending = pending;
|
||||
return true;
|
||||
},
|
||||
|
||||
_cancelTileTearOff() {
|
||||
const pending = this._tileTearOffPending;
|
||||
if (!pending) return;
|
||||
this._tileTearOffPending = null;
|
||||
clearTimeout(pending.timer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a tile as a window of its own (detachSession, which takes it out of
|
||||
* the grid), the tile's size (at least TILE_WINDOW_MIN_W x MIN_H). Placed
|
||||
* with its header under a screen point when given one (`screenX`/`screenY`
|
||||
* and where in the tile it was held, `offsetX`/`offsetY`: a drop outside the
|
||||
* window, a hand let go), otherwise over the tile itself (the ⋯ menu).
|
||||
*
|
||||
* @returns {boolean} whether the session is popped out afterwards
|
||||
*/
|
||||
detachTile(sessionId, { screenX, screenY, offsetX = 0, offsetY = 0, width, height } = {}) {
|
||||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||||
if (!entry || !this._tileDetachAllowed()) return false;
|
||||
const rect = entry.el.getBoundingClientRect();
|
||||
const availW = window.screen?.availWidth || Infinity;
|
||||
const availH = window.screen?.availHeight || Infinity;
|
||||
const size = (v, min, max) => Math.round(Math.min(Math.max(Number.isFinite(v) ? v : min, min), max));
|
||||
const placement = {
|
||||
width: size(width ?? rect.width, TILE_WINDOW_MIN_W, availW),
|
||||
height: size(height ?? rect.height, TILE_WINDOW_MIN_H, availH),
|
||||
};
|
||||
if (Number.isFinite(screenX) && Number.isFinite(screenY)) {
|
||||
placement.left = Math.round(screenX - offsetX);
|
||||
placement.top = Math.round(screenY - offsetY - TILE_WINDOW_CHROME_PX);
|
||||
} else if (Number.isFinite(window.screenX) && Number.isFinite(window.screenY)) {
|
||||
// Over the tile: the page's own chrome sits above its viewport.
|
||||
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
|
||||
placement.left = Math.round(window.screenX + rect.left);
|
||||
placement.top = Math.round(window.screenY + chrome + rect.top - TILE_WINDOW_CHROME_PX);
|
||||
}
|
||||
this.detachSession?.(sessionId, placement);
|
||||
return !!this.detachedSessions?.has(sessionId);
|
||||
},
|
||||
|
||||
// ── For the gesture overlay (packages/gesture-control, src/codeman/entry.ts)
|
||||
//
|
||||
// A hand is no native drag: the overlay asks where it is and what is under
|
||||
// it, and every move still goes through the paths a mouse drag takes
|
||||
// (dropSessionOnTile / dropSessionOnSlot, and so _reorderTiles), the
|
||||
// pop-out through detachTile.
|
||||
|
||||
/** The tiled session whose tile is at viewport point (x, y), or null. */
|
||||
tileAtPoint(x, y) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) return null;
|
||||
const hit = document.elementFromPoint?.(x, y);
|
||||
if (!hit) return null;
|
||||
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(hit)) return id;
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Whether a hand may pick up this tile: it can move, or (Detach Tiles) leave the window. */
|
||||
canGrabTile(sessionId) {
|
||||
return !!this._tileGrid?.has(sessionId) && (this._tilesMovable() || this._tileDetachAllowed());
|
||||
},
|
||||
|
||||
/**
|
||||
* What a session dragged to viewport point (x, y) would drop onto, by the
|
||||
* mouse drag's own rules: another tile (it takes that tile's place, or the
|
||||
* two trade places), an empty cell (it moves or joins there). A tiled
|
||||
* session only while tiles can move (not zoomed). Null for anything else,
|
||||
* a popped-out session included.
|
||||
*
|
||||
* @returns {{kind: 'tile', id: string, el: HTMLElement}|{kind: 'cell', cell: number, el: HTMLElement}|null}
|
||||
*/
|
||||
tileDropTargetAt(x, y, draggedId) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return null;
|
||||
if (grid.tiles.has(draggedId) && !this._tilesMovable()) return null;
|
||||
const hit = document.elementFromPoint?.(x, y);
|
||||
if (!hit) return null;
|
||||
for (const [id, entry] of grid.tiles) {
|
||||
if (entry.el.contains?.(hit)) return id === draggedId ? null : { kind: 'tile', id, el: entry.el };
|
||||
}
|
||||
for (const slot of grid.slots || []) {
|
||||
if (slot.contains?.(hit)) return { kind: 'cell', cell: Number(slot.dataset.cell), el: slot };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Drops a session on a target tileDropTargetAt returned (the hand let go).
|
||||
*
|
||||
* @returns {boolean} whether the session is a tile afterwards
|
||||
*/
|
||||
dropOnTileTarget(draggedId, target) {
|
||||
if (!target || !this._tileGrid?.open) return false;
|
||||
if (target.kind === 'tile') this.dropSessionOnTile(draggedId, target.id);
|
||||
else if (target.kind === 'cell') this.dropSessionOnSlot(draggedId, target.cell);
|
||||
return !!this._tileGrid?.has(draggedId);
|
||||
},
|
||||
|
||||
/** Whether viewport point (x, y) is over the open grid (a hand let go outside it pops the tile out). */
|
||||
isOverTileGrid(x, y) {
|
||||
if (!this._tileGrid?.open) return false;
|
||||
const rect = document.getElementById('tileGrid')?.getBoundingClientRect?.();
|
||||
return !!rect && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
|
||||
},
|
||||
|
||||
/**
|
||||
* A hand let a tile go outside the grid (Detach Tiles): it opens as a window
|
||||
* of its own with its header near that point. A script opening a window
|
||||
* with no click behind it is what popup blockers stop; detachSession then
|
||||
* says so and the tile stays.
|
||||
*/
|
||||
detachTileAtPoint(sessionId, x, y, { offsetX = 0, offsetY = 0 } = {}) {
|
||||
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
|
||||
const sx = (window.screenX || 0) + x;
|
||||
const sy = (window.screenY || 0) + chrome + y;
|
||||
return this.detachTile(sessionId, { screenX: sx, screenY: sy, offsetX, offsetY });
|
||||
},
|
||||
|
||||
/**
|
||||
* A pop-out window's title, as a handle (Detach Tiles): dragged onto a
|
||||
* Codeman window's tile or empty cell, the session docks there as a tile
|
||||
* and this window closes (_dockForeignTile, which asks it to). The same
|
||||
* drag type as a tile's header; on or off follows the setting, also when it
|
||||
* changes in another window.
|
||||
*/
|
||||
_installSoloTileHandle() {
|
||||
const title = document.getElementById('soloSessionTitle');
|
||||
if (!this.isSoloWindow || !title || title._codemanTileHandle) return;
|
||||
title._codemanTileHandle = true;
|
||||
const sync = () => {
|
||||
this._cachedAppSettings = null;
|
||||
const on = this.tileDetachEnabled();
|
||||
title.setAttribute('draggable', on ? 'true' : 'false');
|
||||
title.classList.toggle('solo-session-title--handle', on);
|
||||
const label = on ? "Drag onto a Codeman window's tiles to dock this session there" : '';
|
||||
if (title._codemanTileHandleLabel !== label) {
|
||||
title._codemanTileHandleLabel = label;
|
||||
title.title = label;
|
||||
}
|
||||
};
|
||||
sync();
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (!e.key || e.key === this.getSettingsStorageKey?.()) sync();
|
||||
});
|
||||
title.addEventListener('dragstart', (e) => {
|
||||
if (!this.tileDetachEnabled() || !this.soloSessionId) {
|
||||
e.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData(TILE_DRAG_TYPE, this.soloSessionId);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
|
||||
|
||||
@@ -325,6 +325,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
oncontextmenu="app.handleWebviewTabContextMenu(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
||||
@@ -347,6 +348,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Right-click (or the menu key) on a web tab in the vertical rail opens its
|
||||
* actions (Focus, group moves, settings), the flat rail included, where it is
|
||||
* the only way in. Everywhere else the browser keeps its own menu.
|
||||
*/
|
||||
handleWebviewTabContextMenu(event, id) {
|
||||
if (!this._tabLayoutEditable?.()) return false;
|
||||
return this.openTabWebviewMenu?.(event, id);
|
||||
},
|
||||
|
||||
/** Mark exactly one tab active across BOTH tab kinds. */
|
||||
_updateActiveWebviewTab() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -359,6 +370,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
this._syncTabTreeSelection?.(container);
|
||||
this._renderTabFocus?.();
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,6 +17,15 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
|
||||
/**
|
||||
* Longest path PARAMETER the router accepts (Fastify's default is 100). A file
|
||||
* name goes up to 255 characters and arrives percent-encoded, so the folder
|
||||
* grid's `…/files/:name/raw` answered 404 "Route not found" for a name the
|
||||
* listing happily showed. Raw length, not decoded: 255 four-byte characters are
|
||||
* 3060 characters of `%XX`.
|
||||
*/
|
||||
export const MAX_ROUTE_PARAM_LENGTH = 4096;
|
||||
import type { AuthSessionRecord } from './ports/auth-port.js';
|
||||
import type { StaleExpirationMap } from '../utils/index.js';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
@@ -183,7 +184,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
// Web-tab proxy capabilities are a second credential the cookie purge does not
|
||||
// touch; a forced logout that left them alive would not be a logout.
|
||||
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
audit(req, 'user.logout', username, { revoked, revokedWebviews });
|
||||
const revokedHtmlViews = htmlViewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
audit(req, 'user.logout', username, { revoked, revokedWebviews, revokedHtmlViews });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
@@ -206,6 +208,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
|
||||
@@ -102,14 +102,12 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||
}
|
||||
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
|
||||
// a third-party TUI whose keystroke contract Codeman has not measured, the
|
||||
// Claude-shaped option parser never reads options off its frames, and
|
||||
// verifyStillAnswerable() can therefore never be conclusive for it — so the
|
||||
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
|
||||
// still raises the red alert and clears on the harness's own working/stop
|
||||
// reports; answering happens in the terminal.
|
||||
if (session.mode === 'deepseek') {
|
||||
// Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
|
||||
// dialog belongs to a third-party TUI whose keystroke contract Codeman has
|
||||
// not measured, so '1'/Esc would be a blind keystroke into a foreign
|
||||
// composer. The item still raises the red alert and clears on the harness's
|
||||
// own working/stop reports; answering happens in the terminal.
|
||||
if (item.answerInTerminal) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
|
||||
|
||||
@@ -1536,7 +1536,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body);
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { compileFileQuery } from '../../utils/file-query.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
AUDIO_ATTACHMENT_EXTENSIONS,
|
||||
@@ -30,12 +31,21 @@ import {
|
||||
attachmentRecordToEvent,
|
||||
attachmentRegistry,
|
||||
buildFileThumbnailRoute,
|
||||
getAttachmentType,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
TEXT_ATTACHMENT_EXTENSIONS,
|
||||
VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import {
|
||||
FolderListingError,
|
||||
folderFilePath,
|
||||
folderGrants,
|
||||
folderAllowedForUser,
|
||||
listFolder,
|
||||
validFolderFileName,
|
||||
} from '../../folder-listing.js';
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
@@ -200,8 +210,8 @@ interface FileBodySource {
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
/** Byte source for a LOCAL file (the historical, only path). */
|
||||
function localFileSource(resolvedPath: string) {
|
||||
/** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
|
||||
export function localFileSource(resolvedPath: string) {
|
||||
return (range?: { start: number; end: number }): FileBodySource => ({
|
||||
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
|
||||
});
|
||||
@@ -221,8 +231,11 @@ function localFileSource(resolvedPath: string) {
|
||||
* `open` supplies the bytes for the (optional) range, which is what lets a remote
|
||||
* case reuse this instead of re-implementing the 206/416 contract: same headers,
|
||||
* same status codes, whether the file sits on this host or behind an ssh pipe.
|
||||
*
|
||||
* Exported for the rendered-HTML route (html-view-routes.ts), so a page's own
|
||||
* `<video>`/`<audio>` can seek as well.
|
||||
*/
|
||||
function sendFileBody(
|
||||
export function sendFileBody(
|
||||
reply: FastifyReply,
|
||||
size: number,
|
||||
rangeHeader: string | string[] | undefined,
|
||||
@@ -271,7 +284,9 @@ async function serveRawFile(
|
||||
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
|
||||
// family is servable, so widening what can be READ never widened what can RUN.
|
||||
// The preview overlay reads these through `fetch()`, which ignores the
|
||||
// disposition, so a clicked .html still shows its source.
|
||||
// disposition, so the source view of a clicked .html still works. Rendering it
|
||||
// as a page is html-view-routes.ts's job (sandboxed, opaque origin), never this
|
||||
// route's.
|
||||
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
|
||||
if (download || markupOnly) {
|
||||
reply.header(
|
||||
@@ -426,6 +441,40 @@ async function resolveServableRemoteAttachment(
|
||||
return { path: probe.realPath, probe };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
|
||||
* the attachment raw route, shared with the folder-grid file route.
|
||||
*
|
||||
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
|
||||
* contract, and its size comes from the guard's own re-probe — so serving a remote
|
||||
* file needs no stat the local branch would not also need.
|
||||
*/
|
||||
async function serveServableRaw(
|
||||
reply: FastifyReply,
|
||||
servable: ServableAttachment,
|
||||
remote: SessionRemote | undefined,
|
||||
fileName: string,
|
||||
extension: string,
|
||||
query: { download?: string; preview?: string; range?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(extension, query, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
||||
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
||||
@@ -520,8 +569,20 @@ function getKnownSessionWorkingDir(
|
||||
interface SessionFileScope {
|
||||
workingDir: string;
|
||||
remote?: SessionRemote;
|
||||
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
|
||||
docker?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Docker case is a LOCAL session for the file routes (the workspace is a
|
||||
* bind mount, so its paths are host paths), but `~` inside the container is the
|
||||
* container's own seeded home, not this host's. Expanding `~/x.png` against the
|
||||
* host's home would open a different file than the one the agent printed, so
|
||||
* the form is refused instead of guessed.
|
||||
*/
|
||||
export const DOCKER_HOME_PATH_ERROR =
|
||||
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
|
||||
|
||||
/**
|
||||
* The file-access scope of a session, live or persisted — the `workingDir`-only
|
||||
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
|
||||
@@ -539,12 +600,12 @@ function getKnownSessionFileScope(
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) {
|
||||
return { workingDir: liveSession.workingDir, remote: liveSession.remote };
|
||||
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) {
|
||||
return { workingDir: stored.workingDir, remote: stored.remote };
|
||||
return { workingDir: stored.workingDir, remote: stored.remote, docker: !!stored.docker };
|
||||
}
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
@@ -2143,6 +2204,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
|
||||
return;
|
||||
}
|
||||
if (session.docker && body.path.startsWith('~/')) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// A remote case registers a path that lives on the REMOTE host: the guard and
|
||||
@@ -2263,34 +2328,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
});
|
||||
if (!servable) return;
|
||||
|
||||
try {
|
||||
// A remote record streams over ssh exactly like file-raw, with the same
|
||||
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
|
||||
// serving a remote attachment needs no stat the local branch would not also need.
|
||||
const remote = session.remote;
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(
|
||||
reply,
|
||||
target,
|
||||
size,
|
||||
record.fileName,
|
||||
record.extension,
|
||||
download === 'true',
|
||||
req.headers.range
|
||||
);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
||||
@@ -2354,6 +2396,182 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
await serveThumbnail(reply, servable.path, record.extension);
|
||||
});
|
||||
|
||||
// ===== Folder grid =====
|
||||
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
|
||||
// grid. Same guard as a single external attachment; the grid then fetches files by
|
||||
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
|
||||
app.post('/api/sessions/:id/folders', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return;
|
||||
const body = (req.body || {}) as { path?: unknown };
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
|
||||
return;
|
||||
}
|
||||
// A bare `~` expands to this host's home too, not the container's.
|
||||
if (scope.docker && (body.path === '~' || body.path.startsWith('~/'))) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const listing = await listFolder(id, body.path, {
|
||||
sessionWorkingDir: scope.workingDir,
|
||||
remote: scope.remote,
|
||||
user: getAuthUser(req),
|
||||
});
|
||||
return { success: true, data: listing };
|
||||
} catch (err) {
|
||||
if (err instanceof FolderListingError) {
|
||||
const code =
|
||||
err.statusCode === 404
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: err.statusCode >= 500
|
||||
? ApiErrorCode.OPERATION_FAILED
|
||||
: ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
|
||||
return;
|
||||
}
|
||||
return reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* One file of a granted folder, guard-checked like an attachment: the name is a
|
||||
* bare file name of a supported type, and the joined path goes through the same
|
||||
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
|
||||
* re-resolution, remote re-probe) an attachment by id does. The multi-user rule
|
||||
* ({@link folderAllowedForUser}) runs again on the resolved file: a symlink inside
|
||||
* the granted folder must not lead into another user's space.
|
||||
*/
|
||||
async function resolveFolderFile(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
|
||||
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return null;
|
||||
const grant = folderGrants.get(id, folderId);
|
||||
if (!grant) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
|
||||
return null;
|
||||
}
|
||||
const fileName = validFolderFileName(name);
|
||||
if (!fileName) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
|
||||
return null;
|
||||
}
|
||||
const filePath = folderFilePath(grant.dir, fileName);
|
||||
if (!scope.remote) {
|
||||
// Bounded, like the folder itself: the grant may point at a mount that has stopped answering since.
|
||||
const kind = await probePathKind(filePath, { pastCap: true });
|
||||
if (kind === 'unknown') {
|
||||
reply
|
||||
.code(503)
|
||||
.send(
|
||||
createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('File', filePath, { pastCap: true }))
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (kind !== 'file') {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
|
||||
const record: AttachmentRecord = {
|
||||
attachmentId: '',
|
||||
sessionId: id,
|
||||
filePath,
|
||||
fileName,
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
timestamp: 0,
|
||||
source: 'external',
|
||||
};
|
||||
const servable = await resolveServableAttachmentPath(reply, record, scope);
|
||||
if (!servable) return null;
|
||||
// The requested NAME only opens the door; registration judges the file a
|
||||
// symlink resolves to (supported extension, regular file) and so does this.
|
||||
// Without it `shot.png -> ../secret.bin` is served, and a FIFO or a device
|
||||
// behind a supported name parks a worker on an open that never returns.
|
||||
if (!isSupportedAttachmentExtension(extname(servable.path))) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Unsupported attachment type'));
|
||||
return null;
|
||||
}
|
||||
const isRegularFile = scope.remote
|
||||
? servable.probe?.kind === 'file'
|
||||
: await fs.stat(servable.path).then(
|
||||
(stat) => stat.isFile(),
|
||||
() => false
|
||||
);
|
||||
if (!isRegularFile) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Attachment path is not a file'));
|
||||
return null;
|
||||
}
|
||||
if (!folderAllowedForUser(servable.path, getAuthUser(req), scope)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File is outside your space'));
|
||||
return null;
|
||||
}
|
||||
return { scope, servable, name: fileName, extension };
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
const { download, preview } = req.query as { download?: string; preview?: string };
|
||||
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.extension !== 'docx' && file.extension !== 'pptx') {
|
||||
const { id, folderId } = req.params as { id: string; folderId: string };
|
||||
reply.redirect(
|
||||
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Office document preview is not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Thumbnails are not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveThumbnail(reply, file.servable.path, file.extension);
|
||||
});
|
||||
|
||||
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
||||
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -200,6 +201,10 @@ export function registerHookEventRoutes(
|
||||
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
|
||||
message: typeof safeData.message === 'string' ? safeData.message : undefined,
|
||||
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
|
||||
// Supervised-hook CLIs (DeepSeek Harness): dialog keystrokes are unverifiable,
|
||||
// so the item is an alert — answer in the terminal. Capability-gated, not by
|
||||
// mode id, so a future supervised CLI inherits the same shape.
|
||||
answerInTerminal: getCli(session.mode)?.capabilities.hooks === 'supervised',
|
||||
// What the pane says is still running in the background. An idle prompt from a
|
||||
// session that is watching its own work opens acknowledged, so it never arms an
|
||||
// alert nobody can answer; notePrompt() carries the whole reasoning.
|
||||
@@ -244,7 +249,9 @@ export function registerHookEventRoutes(
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
// Supervised-hook CLIs answer in the terminal: omit approvalId so push
|
||||
// cannot offer Approve/Deny that the answer route would refuse.
|
||||
...(approvalId && session && getCli(session.mode)?.capabilities.hooks !== 'supervised' && { approvalId }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||