Compare commits

..
Author SHA1 Message Date
Codeman maintainer e2453fe97b fix(terminal): partial-history notice only when there is history to load
The "Showing the most recent 1.0 MB of this session. 4.8 MB more may still
be retained." bar covered the top of the terminal on nearly every tab switch,
and for fullscreen claude panes it could never be acted on: `truncated`
measures the server's byte stream, while Load full history asks tmux, which
holds no scrollback for a pane that lives in the alternate screen.

- The capture reads `#{history_size}` in the cursor query it already makes
  and the terminal route returns it as `paneHistoryLines`.
- A reported 0 hides the notice; a positive count replaces the byte gap in
  the message.
- The notice is lazy: it appears once a wheel/touch gesture reaches the top
  of the browser buffer (after the pull that gesture starts), leaves when
  the user scrolls back to live output or switches tabs, and its dismissal
  sticks per session until reload.
- formatHistoryBytes no longer prints "1024 KB" just under 1 MiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:56:37 +02:00
184 changed files with 1807 additions and 12073 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'aicodeman': patch
---
The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field.
@@ -0,0 +1,5 @@
---
'aicodeman': minor
---
Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s).
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': minor
---
Tiles move by hand, and can leave the window. With gesture control on, pinch a tile and carry it onto another tile (they swap) or an empty cell (it moves there), or carry a tab onto the grid to tile it. A new **Detach Tiles** setting (App Settings → Appearance, per device, off by default) lets you drag a tile's header out of the browser to open it in its own window, drop it on another Codeman window's tiles to move it there, and drag a popped-out window's title back onto any grid. The tile's ⋯ menu gets "Open in a new window" too.
+1 -1
View File
@@ -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.40.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+1 -1
View File
@@ -57,7 +57,7 @@ Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
+1 -3
View File
@@ -107,9 +107,7 @@ todo.md
@fix_plan.md
readme-preview.mjs
# Prompt uploads land here under each session working dir (runtime artifact);
# .claude-images/ is where they landed before the move.
.codeman-uploads/
# Uploaded images land here under each session working dir (runtime artifact)
.claude-images/
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
+1 -1
View File
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
-46
View File
@@ -1,51 +1,5 @@
# aicodeman
## 1.41.0
### Minor Changes
- 87f1c9c: ### Thanks
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
![Codeman tile grid: six live agents powering on and off with the CRT tile animation](https://raw.githubusercontent.com/Ark0N/Codeman/08694b5862534b2b1224e9e272ad9858c787ebef/release-1.41/tiles-crt-stats-800.gif)
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
**Fixes.**
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
## 1.40.0
### Minor Changes
+9 -13
View File
File diff suppressed because one or more lines are too long
-18
View File
@@ -944,24 +944,6 @@ codeman tui --list # numbered session list (plain tex
codeman tui 3 # attach to session 3 of that list
```
### `codeman agent` — session-to-session verbs in every CLI mode
The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
```bash
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
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.
### 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.
-2
View File
@@ -22,7 +22,6 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
'test/tab-rail-search.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -46,7 +45,6 @@ export const BROWSER_TEST_GLOBS = [
'test/spreadsheet-preview.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts',
'test/markdown-anchor-links.browser.test.ts',
];
/**
+9 -25
View File
@@ -457,8 +457,13 @@ 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
`Server-Timing` header reports `capture`, `prepare` and `total`.
geometry was read. `paneHistoryLines` (present whenever the body is a pane capture)
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 |
|---|---|
@@ -466,27 +471,6 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
| `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. |
## 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 in modes that never receive the claude-only skill preamble. 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.
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
A `multipart/form-data` body with one `image` part. The file is written into the
session's workspace as `<workingDir>/.codeman-uploads/paste-<ms>-<hex>.<ext>`, and
`data` carries `path` and `filename` for the client to type the path into the
prompt. The folder is Codeman's own: hidden, created on first use with a
`.gitignore` containing `*` (written once, never over a file already there), and
cleaned up the way pasted images always were: `paste-*` files older than 7 days
go in an hourly sweep, and the folder goes when the last session of that
workspace is killed. Uploads made before this release sit in `.claude-images/`;
that folder receives nothing new, and is swept and removed the same way for one
release. A remote (SSH) session answers 400, since the file would land on the
Codeman host under a path the remote agent cannot read. A Docker session of an
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.
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
@@ -833,10 +817,10 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
File diff suppressed because one or more lines are too long
+5 -7
View File
@@ -77,13 +77,11 @@ A test that starts a server binds an ephemeral port, never a fixed one:
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
any `WebServer` built under `test/` on a non-zero port.
- A raw `http`, `net`, Fastify or `ws` server: `listen({ port: 0 })`, then
`address().port`. The guard also fails a raw listen on a number or a `…PORT` constant.
- The mobile suite (`test/mobile/**`) gets its server from `createTestServer()`, which
binds an ephemeral port too; read it from `server.boundPort`.
- The one exception is `test/codex-predictive-echo.test.ts`, which starts a separate lab
server process on port 3222 that the guard cannot see.
any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
excepted).
- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
convention in `test/mobile/README.md` for now.
- Never port 3000: that is the live instance.
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
+4 -6
View File
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
interface CliEntry {
id: CliId; // 'codex'
label: string; // 'Codex' — shown in menus
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
accent: string; // single hex colour
enabled: boolean;
stock: boolean; // set by the loader; a custom entry can never claim it
@@ -265,11 +265,9 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
+34 -101
View File
@@ -45,28 +45,20 @@ or settled a question the spec left open. The invariants as built are in
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
compare with the last English value, not the translated DOM.
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
decision 10 and the layout memory of decision 11): a click (and `Ctrl+Shift+G`, the same
`toggleTileGrid`) brings back the grid this browser last had EXACTLY as the user left
it: which session sits in which cell, holes included, its tile count, divider sizes,
focus and a zoom the user chose (`restoreTileGridCells`, constants.js). It is never
filled to the remembered count and never trimmed to the window (a window too small
for it shows the focused tile alone until it fits, the arrangement kept). A session
that no longer exists frees its cell, which the ranking fills. Only with nothing
stored, or none of its sessions left, does `tileGridOpenSet` (constants.js) take an
open split's two sessions, else the open sessions as `rankTileSessions` orders them
(owner request: "prefer to load in tiles that are working and then the most recent,
so the oldest don't get opened"): WORKING first (the most recently started turn
first, keyed off `lastSubmitAt` only), then the ones NEEDING INPUT (the red and yellow
tab alerts), then the rest by most recent activity, tab order breaking ties; the
active one always included and focused, and `tileGridSetForCount` trims it (from the
end, the session to focus kept) or fills it (from the ranking) to the remembered count
(default 6, at most what the window fits). The states and stamps are the home
screens' own (`_mobileOverviewState`, `sessionActivityAnchor`). A remembered grid still
wins over an open split: the split closes and its sessions are not seeded first. A
page-load restore brings back the same grid as the toggle. Ctrl/Cmd+click on a tab
with the grid closed opens what the toggle would with that session among the tiles and
focused, never past the count (owner answer: N, not N+1): it joins the first empty cell
while the grid holds fewer than the count, else it takes the last tile's place.
decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered
count of tiles (default 6, at most what the window fits). `tileGridOpenSet`
(constants.js) picks the grid this tab last had, else an open split's two sessions, else
the open sessions in tab order, the active one always included and focused, and
`tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it
(from tab order) to the count. A remembered grid comes back with its tiles first, in
their cells, then sessions in tab order, to the count in total: the count is a shape
change under the cell model's rule (`reformTileCells`: the tiles keep their row and
column when all fit, else they pack in reading order) and the added tiles fill the empty
cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A
remembered grid still wins over an open split: the split closes and its sessions are not
seeded first. A page-load restore brings back exactly the stored grid, whatever the
count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that
session among them and focused (owner answer: N, not N+1).
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
to right click over the tile button to adjust it"). It replaces the button's native title:
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
@@ -88,10 +80,9 @@ or settled a question the spec left open. The invariants as built are in
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
element close it (the single view a close starts focuses its terminal when its replay
lands; a menu left open behind that would send its keys there). A pick is remembered per
device in `codeman:tile-count` and opens that many tiles (a stored grid re-formed to
it, its tiles first in their cells); with the grid open it re-forms it
(`_reformTileGrid`): the focused tile always stays, the others leave from the end or
join from the ranking, filling empty cells first,
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
stays, the others leave from the end or join from tab order, filling empty cells first,
every joining tile mounted and laid out before any connects (one fit, one PTY resize
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
"Open group as tiles", Run) still add up to the cap of 6.
@@ -106,10 +97,7 @@ or settled a question the spec left open. The invariants as built are in
`test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
own `settle` style, the default of App Settings → Animations → Tile Animations, which
switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
`beam`, ...; docs/architecture-invariants.md#entrance-animations).
Opening, each tile
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
@@ -401,34 +389,16 @@ the harness/model request; see "As built")
### Persistence
Decided: per device (per browser), restored on reload when it was open, and by
the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the
server:
Decided: per device, restored on reload. Stored in localStorage key
`codeman:tile-grid`:
```json
{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…",
"zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] }
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
"colFr": [1, 1, 1], "rowFr": [1, 1] }
```
Session ids and the layout, never content. `ids` are the CELLS in reading order,
`null` for an empty one. `count` is how many tiles the user's own last change
left (open, add, remove by hand, a count picked): a session that goes away by
itself (deleted, popped out, its socket refused) does not lower it, so the next
time the grid opens the ranking fills that place, while a hole the user made
stays. It is written on every change (a move, a divider drag at pointer-up, a
tile added or removed, a count picked, a focus, a zoom) and kept, as
`open: false`, however the grid closes: the toggle, a non-tiled tab,
`leaveTiles` or a `#session=` link (which flips `open` only, so a gone id still
frees its cell), Home, the width gate, the last tile, "Open group as tiles",
closing or killing sessions. Nothing is written while a stored grid is being
put back, so a half-built grid never overwrites it.
A pure sanitizer drops unknown, deleted, detached and duplicate ids on load,
reports the cells their sessions freed (`freed`), and derives `count` for a
value written before it existed (the number of sessions the cells name); the
old packed `ids` (no nulls) read as cells with no hole, and anything that is not
a v1 object is ignored. The format stays `v: 1`, so an older build still reads
a newer value (it ignores `count`). Never read or written in a solo window.
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
duplicate ids on load. Never restored in a solo window.
The restore runs INSIDE `handleInit`, in place of its initial
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
@@ -439,17 +409,6 @@ the main terminal never loads on that page load. A later `handleInit` (SSE
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
against the live list without rebuilding tiles that are still alive.
A cell freed since the grid was stored is filled during that restore, from a
ranking that knows each session's status and stamps (the init payload) but not
yet its pending approvals: `seedApprovals` asks the server for them
asynchronously, and the restore has run by the time they land. So on a reload a
session waiting on a permission dialog or an unseen finished turn ranks with the
quiet ones for that one fill (working sessions still rank first). Accepted: a
fill held back for the approvals would open fewer tiles, which can be another
shape, and then reshape the grid and move the user's tiles a second after the
reload; so approvals that land later never re-form a restored grid. The Tiles
toggle, run once the page has loaded, ranks with them.
### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
@@ -856,8 +815,7 @@ Separate follow-up PRs worth doing (see "Follow-ups").
open, and an open menu owns the Escape (it closes alone and the keyboard goes
back to the Tiles button, like the tab-group menu).
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
- **No secrets in localStorage**: the stored grid holds session ids and its
layout only, never content.
- **No secrets in localStorage**: the stored grid holds ids only.
- **Memory**: everything a tile creates is released in `destroy()`.
## Delivery: two PRs
@@ -1050,17 +1008,13 @@ exits green. Use the browser runner for those files and read the file count.
viewer keeps a stale width and renders garbled output (#464).
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
on drain) for grids over slow links.
4. Tile parity extras: a "Load full history" action inside a tile. (Done
since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
the primary pane's #555 route, hand-reports a plain click while its session
has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
renderer as SGR wheel reports from its own cells
(`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
since xterm turns it into a horizontal no-op off macOS.)
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
a "Load full history" action inside a tile. (Done since: a tile pages a
hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's
#555 route, and hand-reports a plain click while its session has
`cliMouseTracking` on, both through the primary pane's gates aimed at the
tile. The SGR wheel forwarding itself is still open: a fullscreen Claude
tile leaves the wheel to xterm.)
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
6. Named grid presets, possibly per owner on the server.
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
@@ -1099,8 +1053,7 @@ exits green. Use the browser runner for those files and read the file count.
is on right-click of the button (its title says so, as do the wiki and the
Help modal). Superseded in part by decision 10: right-click is now the count
menu, and a remembered grid is filled to the count instead of opening
exactly as stored; decision 11 then restored "exactly as stored" and put a
ranking in place of the tab order.
exactly as stored.
9. **No + in the tile header.** Decided by the owner ("remove the + button from
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
session in this case" went with it. Tiles are added from the Tiles button
@@ -1113,32 +1066,12 @@ exits green. Use the browser runner for those files and read the file count.
right-click menu offers 2, 4 and 6, remembered per device; the session
picker is gone, and decision 8's "picker on right-click" is superseded. The
owner's answers on the details: the count wins over a remembered grid's
size (its tiles first, in their cells, holes filled first, then tab order;
superseded by decision 11: a click brings the remembered grid back as it
was, and only a count picked in the menu re-forms it);
size (its tiles first, in their cells, holes filled first, then tab order);
Ctrl/Cmd+click with the grid closed opens the count in total, that session
focused; shrinking keeps the focused tile; only the toggle animates the
close; a remembered count larger than the window stays checked but greyed
and a click opens what fits; the close keeps its dimmed still until the
single view has painted (at most 700 ms); paced connect is in.
11. **The grid keeps the layout the user arranged, and a fresh one ranks by
work.** Decided by the owner ("when I hit the tiles button, it should prefer
to load in tiles that are working and then the most recent working, so the
oldest dont get opened ... when I moved around and modified it, save it per
browser the layout, so when I turn tiles off and on, always keep what the
last setting was, if there was no setting before take the working ones, that
ones needs input and then the most recent ones in order"). The layout
(cells and holes, tile count, divider sizes, focus, a zoom the user chose)
is saved per browser on every change and comes back exactly from the toggle,
however the grid closed, and from a reload when the grid was open (a grid
closed before the reload stays remembered for the toggle; the page shows the
single view); it is never filled to the remembered count nor trimmed to the
window. A session gone since frees its cell for the
ranking; with none left, the grid opens from the ranking (`rankTileSessions`:
working, then needing input, then most recent), which also fills every place
the grid fills on its own (a count picked in the menu, a freed cell, an open
split's fill). Supersedes decision 10's "the count wins over a remembered
grid's size"; the count menu itself, its counts and its other answers stay.
## Code anchors
+3 -2
View File
@@ -63,8 +63,9 @@ provide what they need; that means "not runnable here", not a regression.
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
read `server.boundPort`. Never 3000.
or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
because that helper caches servers by port.
## Finding your way around
+1 -32
View File
@@ -4,8 +4,7 @@ Everything the dashboard does is HTTP, so an agent can do it too. This page is f
that makes Codeman interesting: **Claude Code running inside a Codeman session, spawning and
supervising other sessions.**
Three routes. In a Claude session, start with the skill. In any other CLI mode, use the
`codeman agent` commands. Raw HTTP is there for everything else.
Two routes. Start with the skill.
## The agent skill
@@ -60,36 +59,6 @@ DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alph
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
completion signals.
## The `codeman agent` commands
The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is
a thin client over the endpoints in [the manual path](#the-manual-path), so auth and
ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a
case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
```bash
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
ambiguous prefix.
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
prompt is output too, so ask for the marker in halves and wait on the joined form.
- **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 manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
+2 -12
View File
@@ -90,6 +90,7 @@ every session or only the active tab.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
@@ -98,22 +99,11 @@ every session or only the active tab.
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
| Tall Tabs | Taller tab strip. |
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
| 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). |
| Overview Home Screen | The phone home screen. On by default. |
### Animations
All per device, all off by default, applied as you pick them.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
### Models
Claude model cards, the 1M context window switch, the thinking effort segment and the
@@ -152,7 +142,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
### Notifications
+5 -6
View File
@@ -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. 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
@@ -73,10 +73,6 @@ precedence there.
One tab per session, in your order, and that order syncs across your devices.
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
headers and the Run menus keep theirs.
**Status is carried by the dot and the tab's own styling:**
| Look | Meaning |
@@ -216,7 +212,10 @@ Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
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
+16 -36
View File
@@ -16,32 +16,23 @@ It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift
## Opening a grid
- **Tiles button**: one click shows the tiles straight away. If you have used the grid in
this browser before, you get it back exactly as you left it: the same sessions in the
same places, an empty place where you left one, the same number of tiles, your column
widths and row heights, the tile you were in, and a zoomed tile still zoomed. A session
closed since frees its place, which is filled the way a new grid is filled (below).
Otherwise, or when none of those sessions is left, you get as many tiles as you last
chose (six until you choose; fewer if the window is too small or you have fewer sessions
open): an open split's two first; otherwise the sessions that are working (the most
recently started first), then the ones waiting on you (red and yellow tabs), then the
rest, the most recently used first, so the oldest are the ones left out. The session you
are on always comes along and is focused. With the grid open, the same button closes it.
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
(six until you choose; fewer if the window is too small or you have fewer sessions open).
You get the grid you last had, its tiles where they were, topped up with your open
sessions in tab order; if there is none, an open split's two first; otherwise your open
sessions in tab order, with the session you are on focused. With the grid open, the same
button closes it.
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
count you chose and what a click and a right-click do.
count it opens and what a click and a right-click do.
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered in this
browser. Picking a count opens the grid with that many tiles (the grid you left, its
tiles in their places, new ones in the empty places first), and it is what a click opens
when there is no grid to bring back. With the grid open, picking a count re-forms it: the
tile you are in always stays, extra tiles leave from the end, new ones join working ones
first, then the ones waiting on you, then the most recent. A count the window is too
small for is greyed out, with the reason.
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
and is what the next click opens. With the grid open, picking a count re-forms it: the
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
order. A count the window is too small for is greyed out, with the reason.
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
the grid closed it opens what the Tiles button would show with that session added: in the
empty place while the grid has fewer tiles than the count you chose, else in place of the
last tile (never more than the count). On macOS use
the grid closed it opens what the Tiles button would show, with that session among them
(still the count you chose in total). On macOS use
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
@@ -98,8 +89,7 @@ keeps the focus.
A moved tile takes the size of the place it lands in: column widths and row heights stay
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
the empty slot included, is saved with the grid and comes back when you turn the grid off
and on, and on a page reload while the grid is open.
the empty slot included, is saved with the grid and comes back on reload.
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
@@ -132,18 +122,8 @@ session finder) shows that session on its own, the normal single view. The grid
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
same. Narrowing the window below the desktop width also returns to the single view.
The grid is saved in this browser every time you change it (moving, resizing, adding or
removing a tile, changing the count, focusing or zooming a tile), and never sent to the
server. However you leave it (the Tiles button, another tab, Home, a link, closing its last
tile or session), the Tiles button brings it back as it was. A page reload brings it back
when the grid was open; after you left it, a reload shows the single view and the Tiles
button still brings the grid back. A session that was closed or popped out into its own
window in the meantime frees its place for another one, picked the way a new grid picks
them; the place stays empty only when no other session is left. Right after a page reload
the page does not know yet which sessions are waiting for your answer, so that pick goes by
which sessions are working and which you used last. If the window has become too small for
all the tiles, the tile you were in fills the grid until the window is wide enough again,
and the rest of the layout is kept.
The grid is saved on this device and comes back when you reload the page, with its focus,
zoom and column widths. A session that was closed in the meantime is simply left out.
Split shows the same logo, name and model above both of its panes.
+1 -1
View File
@@ -14,7 +14,7 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.41.0",
"version": "1.40.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.41.0",
"version": "1.40.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -13389,7 +13389,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.4.1",
"version": "0.4.0",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "aicodeman",
"version": "1.41.0",
"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.",
"version": "1.40.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -42,7 +42,7 @@
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json",
"knip": "npx --yes knip@latest --config config/knip.json",
"release": "node scripts/npm-release.mjs"
"release": "changeset publish"
},
"prettier": {
"singleQuote": true,
@@ -171,7 +171,7 @@
"bugs": {
"url": "https://github.com/Ark0N/Codeman/issues"
},
"homepage": "https://getcodeman.com",
"homepage": "https://github.com/Ark0N/Codeman#readme",
"files": [
"dist",
"scripts/postinstall.js",
-7
View File
@@ -133,13 +133,6 @@ 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`.
+6 -182
View File
@@ -27,14 +27,6 @@
// • 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
@@ -60,22 +52,11 @@ 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
@@ -100,20 +81,11 @@ 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 {
@@ -135,29 +107,6 @@ 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';
@@ -275,7 +224,7 @@ class GestureBridge {
await this.gc.start();
this.running = true;
this.button.classList.add('on');
this.status.textContent = 'on: pinch a tab, tile, window, or button';
this.status.textContent = 'on — pinch a tab, 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".
@@ -346,35 +295,6 @@ 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;
@@ -388,7 +308,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, target: null });
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false });
this.positionGhost(ghost, x, y);
return;
}
@@ -405,42 +325,13 @@ 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;
// 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) {
if (pulled !== grab.armed) {
grab.armed = pulled;
grab.ghost.classList.toggle('cg-armed', pulled);
this.status.textContent = target
? 'release to tile it here'
: pulled
? 'release to float out'
: 'on — pinch a tab';
this.status.textContent = pulled ? 'release to float out' : 'on — pinch a tab';
}
return;
}
@@ -463,44 +354,17 @@ 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, tile, window, or button';
this.status.textContent = 'on — pinch a tab, 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');
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);
if (grab.armed) this.floatPanel(grab.id, x, y);
else this.flash('cancelled');
return;
}
@@ -641,33 +505,6 @@ 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.
@@ -675,11 +512,6 @@ 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');
@@ -693,7 +525,6 @@ 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 {
@@ -776,13 +607,6 @@ 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;
-46
View File
@@ -1,51 +1,5 @@
# xterm-zerolag-input
## 0.4.1
### Patch Changes
- 87f1c9c: ### Thanks
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
![Codeman tile grid: six live agents powering on and off with the CRT tile animation](https://raw.githubusercontent.com/Ark0N/Codeman/08694b5862534b2b1224e9e272ad9858c787ebef/release-1.41/tiles-crt-stats-800.gif)
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
**Fixes.**
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
## 0.4.0
### Minor Changes
+1 -1
View File
@@ -537,7 +537,7 @@ While flushed text exists the prompt column is locked, so a full-screen redraw c
### Scroll awareness
The overlay hides while the cursor row is scrolled out of the viewport and re-renders, debounced, when it scrolls back into view. A viewport parked a few rows above the bottom keeps painting as long as the cursor row is on screen. A buffer that reports no `cursorY` keeps the bottom-only rule.
The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
"version": "0.4.1",
"version": "0.4.0",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
@@ -72,20 +72,3 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
return '';
}
}
/**
* Whether the row the overlay draws on (the cursor row) is inside the viewport. A bare
* `viewportY === baseY` test is wrong for a host that parks the viewport a few rows above
* the bottom with the prompt still on screen; a buffer without `cursorY` keeps that rule.
*/
export function promptRowInViewport(terminal: XtermTerminal): boolean {
try {
const buf = terminal.buffer.active;
if (buf.viewportY === buf.baseY) return true;
if (typeof buf.cursorY !== 'number') return false;
const cursorRow = buf.baseY + buf.cursorY;
return cursorRow >= buf.viewportY && cursorRow < buf.viewportY + terminal.rows;
} catch {
return false;
}
}
@@ -8,7 +8,7 @@ import type {
FontStyle,
} from './types.js';
import { getCellDimensions } from './cell-dimensions.js';
import { findPrompt, readTextAfterPrompt, promptRowInViewport } from './prompt-finder.js';
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
@@ -122,10 +122,11 @@ export class ZerolagInputAddon implements XtermAddon {
// Cache font properties
this._cacheFont();
// Scroll detection: hide the overlay while the cursor row is scrolled out of view
// Scroll detection: hide overlay when scrolled away from bottom
this._scrollHandler = () => {
try {
if (!promptRowInViewport(this._terminal!)) {
const buf = this._terminal!.buffer.active;
if (buf.viewportY !== buf.baseY) {
this._overlay!.style.display = 'none';
if (this._scrollTimer) {
clearTimeout(this._scrollTimer);
@@ -564,8 +565,8 @@ export class ZerolagInputAddon implements XtermAddon {
try {
const buf = this._terminal.buffer.active;
// Hide the overlay while the cursor row is scrolled out of view
if (!promptRowInViewport(this._terminal)) {
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
if (buf.viewportY !== buf.baseY) {
this._overlay.style.display = 'none';
return;
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { promptRowInViewport, findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import type { XtermTerminal, PromptFinder } from '../src/types.js';
function term(lines: string[]) {
@@ -156,28 +156,3 @@ describe('readTextAfterPrompt', () => {
cleanup();
});
});
describe('promptRowInViewport', () => {
const term = (viewportY: number, baseY: number, cursorY: number | undefined, rows = 24) =>
({ rows, buffer: { active: { viewportY, baseY, cursorY, getLine: () => undefined } } }) as never;
it('is true at the bottom regardless of the cursor', () => {
expect(promptRowInViewport(term(10, 10, undefined))).toBe(true);
});
it('is true for a viewport parked above the bottom while the cursor row is on screen', () => {
// scrollToLastNonEmptyLine() parks rows - 2 above the last non-empty row
expect(promptRowInViewport(term(0, 16, 5))).toBe(true);
// cursor exactly on the last visible row
expect(promptRowInViewport(term(0, 23, 0))).toBe(true);
});
it('is false once the cursor row is scrolled out of the viewport', () => {
expect(promptRowInViewport(term(0, 24, 0))).toBe(false); // one past the last row
expect(promptRowInViewport(term(0, 200, 3))).toBe(false); // deep in history
});
it('keeps the bottom-only rule when the buffer has no cursorY', () => {
expect(promptRowInViewport(term(0, 1, undefined))).toBe(false);
});
});
@@ -725,34 +725,3 @@ describe('ZerolagInputAddon', () => {
});
});
});
describe('viewport scrolled away from the bottom', () => {
function parked(viewportY: number, baseY: number, cursorY: number, rows = 24) {
const mock = createMockTerminal({ buffer: { lines: ['$ '], viewportY, baseY, cursorY }, rows });
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
return { addon, mock, overlay };
}
it('still paints with the viewport parked above the bottom while the cursor row is on screen', () => {
// The host parks the viewport to keep trailing blank rows out of view; the
// prompt and cursor are still visible, so the user's text must be too.
const { addon, overlay } = parked(0, 1, 0);
addon.appendText('abc');
expect(addon.pendingText).toBe('abc');
expect(overlay.style.display).not.toBe('none');
expect(overlay.textContent).toContain('abc');
});
it('hides once the cursor row is scrolled out of the viewport, even over a stale prompt glyph', () => {
const { addon, overlay } = parked(0, 30, 0);
addon.appendText('abc');
expect(addon.pendingText).toBe('abc');
expect(overlay.style.display).toBe('none');
});
});
+1 -1
View File
@@ -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.40.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
-6
View File
@@ -29,12 +29,6 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Workers in **every other mode** never receive this preamble, but they have the same
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
URL) enforced in code.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env node
/**
* @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside.
*
* npmjs.com renders the package's top-level `readme`, and npm picks it at publish time:
* @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and
* keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the
* Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months.
* `files` cannot help: npm-packlist always includes every root `README.*`.
*
* Renaming the file would break every link to it, so for the length of the publish only it
* moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards,
* whatever the publish did. The move happens HERE, inside the publish command, never as a
* step before `changesets/action` in release.yml: that action also runs the version path and
* commits the working tree into its version PR, which would commit the deletion.
*
* A previous run killed between the move and the restore leaves the aside copy behind; the
* next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md.
*
* node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`)
*/
import { existsSync, renameSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const HIDDEN_README = 'README.zh-CN.md';
export const ASIDE_NAME = '.README.zh-CN.md.release-aside';
/**
* Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also
* when `publish` fails or throws. Returns the exit code `publish` returned.
*
* @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts
* @returns {number}
*/
export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) {
const original = join(root, HIDDEN_README);
const aside = join(root, ASIDE_NAME);
if (existsSync(aside) && !existsSync(original)) {
renameSync(aside, original);
log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`);
}
const moved = existsSync(original);
if (moved) {
renameSync(original, aside);
log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`);
}
try {
return publish();
} finally {
if (moved) {
renameSync(aside, original);
log(`npm-release: ${HIDDEN_README} restored`);
}
}
}
function runChangesetPublish() {
const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' });
if (result.error) {
console.error(`npm-release: could not run changeset publish: ${result.error.message}`);
return 1;
}
return result.status ?? 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish });
}
-6
View File
@@ -29,12 +29,6 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Workers in **every other mode** never receive this preamble, but they have the same
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
URL) enforced in code.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
-980
View File
@@ -1,980 +0,0 @@
/**
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
* inside a Codeman session, in every CLI mode.
*
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
* preamble — has the same verbs from one line in its AGENTS.md.
*
* Invariants (each asserted in `test/cli-agent.test.ts`):
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
* never guesses a URL — a server you are not part of is not yours to drive.
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
* `interrupt`, which never appends `\r`. A stray control byte is a dead session
* in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
* 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
* direction refuses. Ids appear in full and 8-char form, so equality alone
* misses a real combination — and the miss deletes the caller.
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
* verb. `9` would resolve to whichever session is alone with that first
* character, the user's own interactive tab included.
*
* Commands live here as functions returning an exit code, not calling
* `process.exit`, so the whole surface is unit-testable against a fake server.
*
* @module cli-agent
*/
import http from 'node:http';
import https from 'node:https';
import type { Command } from 'commander';
import {
basicAuthHeader,
credentialsFrom,
readCodemanEnvFile,
type CodemanCredentials,
} from './codeman-credentials.js';
import { getCli } from './config/cli-registry/registry.js';
import { GLYPH, palette, table } from './cli-style.js';
import { getErrorMessage } from './types.js';
import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js';
// ─────────────────────────────────────────────────────────────────────────────
// Context and guard
// ─────────────────────────────────────────────────────────────────────────────
export interface AgentContext {
/** Base URL of the Codeman server, from `CODEMAN_API_URL`. */
apiUrl: string;
/** This session's id, from `CODEMAN_SESSION_ID`. */
selfId: string;
/** Basic-auth credentials, when the server has a password. */
auth?: CodemanCredentials;
}
/** Thrown when the process is not inside a Codeman-managed session. */
export class AgentGuardError extends Error {}
/** Exit codes shared by every verb; a shell agent can branch on them. */
export const EXIT = {
ok: 0,
error: 1,
timeout: 2,
dead: 3,
refused: 4,
} as const;
/**
* Resolve the context from the environment, or throw `AgentGuardError`.
*
* Credentials in the order every client of the API uses (`credentialsFrom`, shared
* with `codeman attach` and the TUI): each field from the environment (a session
* inherits the server's), then the data dir's `.env`. No password means the server
* is open (single-user) — or it is not, and the 401 says so.
*/
export function resolveAgentContext(
env: NodeJS.ProcessEnv = process.env,
envFile: () => Record<string, string> = readCodemanEnvFile
): AgentContext {
if (env.CODEMAN_MUX !== '1') {
throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.');
}
const apiUrl = env.CODEMAN_API_URL?.trim();
if (!apiUrl) {
throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.');
}
const selfId = env.CODEMAN_SESSION_ID?.trim();
if (!selfId) {
throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.');
}
const credentials = credentialsFrom(env, envFile());
return { apiUrl, selfId, auth: credentials.password ? credentials : undefined };
}
// ─────────────────────────────────────────────────────────────────────────────
// Pure helpers (the invariants)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Is `id` this session? Prefix in BOTH directions, because ids appear in full and
* in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id
* shorter than 8 characters cannot prove anything and is treated as "maybe me".
*/
export function isSelfSession(selfId: string, id: string): boolean {
if (!id || selfId.length < 8) return true;
return id.startsWith(selfId) || selfId.startsWith(id);
}
/** Why `rm` refuses, or `undefined` when the delete may go ahead. */
export function deleteRefusal(selfId: string, id: string): string | undefined {
if (!id) return 'refusing: empty session id';
if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me';
if (isSelfSession(selfId, id)) return `refusing: ${id} is me`;
return undefined;
}
/**
* The prompt `send` was given, which must be ONE argument. Joining several with spaces
* would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
* back into a single line, so the multi-line refusal below would never see it.
*/
export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
if (words.length === 1) return { text: words[0] };
return {
error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send <id> "…"\`; a prompt that starts with "-" goes after --: \`send <id> -- "- fix the bug"\`)`,
};
}
/**
* Why `send` refuses this text, or `undefined` when it is printable. The composer
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
* DEL) reaches the pane as a keypress. None of that is a prompt.
*/
export function inputRefusal(text: string): string | undefined {
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)';
// The composer is one line: the server strips newlines, which silently joins the
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
if (/[\n\r\t]/.test(text)) {
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
}
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
// eslint-disable-next-line no-control-regex
const control = text.match(/[\x00-\x1f\x7f-\x9f]/);
if (control) {
const code = control[0].charCodeAt(0).toString(16).padStart(2, '0');
return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`;
}
return undefined;
}
/**
* Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the
* caller asked to type without submitting. Never anything else.
*/
export function buildSendBody(
text: string,
options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number }
): Record<string, unknown> {
const body: Record<string, unknown> = {
input: options.enter ? `${text}\r` : text,
useMux: true,
clientId: options.clientId,
seq: options.seq,
};
if (options.wait !== undefined) body.wait = options.wait;
if (options.waitTimeout !== undefined) body.waitTimeout = options.waitTimeout;
return body;
}
/** Body for the interrupt path: a bare ESC, and nothing appended — ever. */
export function buildInterruptBody(clientId: string, seq: number): Record<string, unknown> {
return { input: '\u001b', useMux: true, clientId, seq };
}
/** `clientId` for this caller: fixed per sending session, so `seq` stays monotonic. */
export function defaultClientId(selfId: string, suffix = ''): string {
return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
}
/**
* Strip a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window
* titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in.
*/
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
}
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${flag} must be a positive integer, got "${raw}"`);
}
return n;
}
/**
* Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
* timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
* during `--until stop` comes back as `ended:true, signal:null` (the registry only
* satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
* a dead worker as `ended:true, matched:false` — both are "dead", never "done".
*/
export function waitExitCode(wait: WaitResult | undefined): number {
if (!wait) return EXIT.error;
if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
if (wait.timedOut) return EXIT.timeout;
if (wait.matched === false) return EXIT.timeout;
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// HTTP
// ─────────────────────────────────────────────────────────────────────────────
export interface ApiEnvelope<T = unknown> {
success: boolean;
data?: T;
error?: string;
errorCode?: string;
}
export interface ApiResponse<T = unknown> {
status: number;
/** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */
json?: ApiEnvelope<T>;
text: string;
}
export interface WaitResult {
/** The signal that fired, or null when the wait ended without one. */
signal?: string | null;
timedOut?: boolean;
/** The session went away (deleted / torn down / the write failed) before the wait resolved. */
ended?: boolean;
timeoutMs?: number;
until?: string[];
matched?: boolean;
match?: string;
snippet?: string;
immediate?: boolean;
}
export interface RequestOptions {
method: 'GET' | 'POST' | 'DELETE';
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: Record<string, unknown>;
headers?: Record<string, string>;
/** Socket timeout; long-polls pass their own timeout plus headroom. */
timeoutMs?: number;
}
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 authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
if (authorization) headers.Authorization = authorization;
return headers;
}
/** The real transport. `rejectUnauthorized:false` because the HTTPS install uses a self-signed cert. */
export const httpRequest: ApiRequest = (ctx, options) => {
const url = new URL(options.path, ctx.apiUrl);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers: Record<string, string | number> = { ...baseHeaders(ctx), ...(options.headers ?? {}) };
if (bodyText !== undefined) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(bodyText);
}
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: options.method,
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
timeout: options.timeoutMs ?? 30_000,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8');
let json: ApiEnvelope | undefined;
try {
json = JSON.parse(text) as ApiEnvelope;
} catch {
json = undefined;
}
resolve({ status: res.statusCode ?? 0, json, text });
});
}
);
req.on('timeout', () => req.destroy(new Error(`request timed out after ${options.timeoutMs ?? 30_000} ms`)));
req.on('error', reject);
if (bodyText !== undefined) req.write(bodyText);
req.end();
});
};
/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
export function describeFailure(res: ApiResponse): string {
if (res.json && !res.json.success) {
return `${res.json.errorCode ?? 'ERROR'}: ${res.json.error ?? 'request failed'} (HTTP ${res.status})`;
}
const text = res.text.trim().split('\n')[0] ?? '';
if (res.status === 401)
return `HTTP 401 ${text}: the server wants a password (CODEMAN_PASSWORD, or the data dir's .env)`;
return `HTTP ${res.status}${text ? ` ${text}` : ''}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────────────────────
export interface AgentIo {
out: (line: string) => void;
err: (line: string) => void;
}
export interface AgentDeps {
ctx: AgentContext;
request: ApiRequest;
io: AgentIo;
json: boolean;
/** Clock for `seq`; injectable so tests are deterministic. */
now?: () => number;
}
/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
function emitJson(deps: AgentDeps, data: unknown): void {
deps.io.out(JSON.stringify(data, null, 2));
}
function fail(deps: AgentDeps, message: string, code: number = EXIT.error): number {
if (deps.json) {
deps.io.out(JSON.stringify({ success: false, error: message }));
} else {
deps.io.err(palette.err(`${GLYPH.fail} ${message}`));
}
return code;
}
interface SessionRow {
id: string;
name?: string;
mode?: string;
status?: string;
workingDir?: string;
pid?: number | null;
parentSessionId?: string | null;
}
/** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */
const FULL_ID_LENGTH = 36;
/**
* Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor
* the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts).
*/
export const MIN_ID_PREFIX_LENGTH = 8;
/**
* Turn the id a human typed into the one the routes accept. `ls` prints 8-char
* prefixes and the routes answer 404 to those (measured live), so anything shorter
* than a full id resolves through the session list; an ambiguous prefix refuses
* rather than picking one. Below 8 characters it refuses before the list: "unique"
* means nothing for `9` — it names whatever session happens to be alone with that
* first character, and `rm`/`send` would act on it.
*/
export async function resolveSessionId(
deps: AgentDeps,
id: string
): Promise<{ id: string } | { error: string; code: number }> {
if (!id) return { error: 'refusing: empty session id', code: EXIT.refused };
if (id.length < MIN_ID_PREFIX_LENGTH) {
return {
error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`,
code: EXIT.refused,
};
}
if (id.length >= FULL_ID_LENGTH) return { id };
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error };
const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
if (matches.length === 1) return { id: matches[0].id };
if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error };
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
}
/** `agent ls` — every session the caller can see, self marked. */
export async function agentLs(deps: AgentDeps): Promise<number> {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return fail(deps, describeFailure(res));
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
if (deps.json) {
emitJson(
deps,
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
);
return EXIT.ok;
}
if (sessions.length === 0) {
deps.io.out(palette.muted('(no sessions)'));
return EXIT.ok;
}
const rows = sessions.map((s) => [
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
s.id.slice(0, 8),
s.mode ?? '?',
s.status ?? '?',
s.name || s.workingDir || '',
]);
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
deps.io.out(
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
);
return EXIT.ok;
}
export interface SpawnOptions {
caseName: string;
mode: string;
name?: string;
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
ready: boolean;
timeoutMs: number;
}
/**
* Agent-scratch label for a case directory a spawn CREATES (the server applies it only
* when quick-start makes the directory). The Add Case UI offers a recursive delete for
* such directories, so this header must never ride any other request: mislabelling a
* real repo there is the one failure in this area that costs actual work.
*/
export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const;
/**
* What the mode's TUI draws once its composer can take a prompt, from the CLI registry
* (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait.
*/
export function composerReadyMark(mode: string): string | undefined {
return getCli(mode)?.capabilities.composerReadyMark;
}
/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> {
const body: Record<string, unknown> = {
caseName: options.caseName,
mode: options.mode,
parentSessionId: deps.ctx.selfId,
};
if (options.name) body.sessionName = options.name;
const res = await deps.request(deps.ctx, {
method: 'POST',
path: '/api/v1/quick-start',
body,
headers: { ...AGENT_ORIGIN_HEADER },
});
const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
const sid = data.sessionId;
let ready: boolean | undefined;
let readinessError: string | undefined;
let dead = false;
const mark = composerReadyMark(options.mode);
if (options.ready && mark) {
const wait = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
// composer never showed up": report the real reason instead of the trust-dialog hint.
if (!wait.json?.success) readinessError = describeFailure(wait);
else {
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
// A worker that died while we waited is exit 3 like every other wait, not a
// "composer not seen" timeout that sends the caller looking for a dialog.
dead = waitExitCode(result) === EXIT.dead;
ready = !dead && Boolean(result?.matched);
}
}
if (deps.json) {
emitJson(deps, { ...data, ready, readinessError });
} else {
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
const say = (line: string) => deps.io.err(line);
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
if (ready === false && !dead) {
say(
palette.warn(
`${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)`
)
);
}
if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`));
if (ready === undefined && !readinessError && options.ready) {
say(
palette.muted(
` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize`
)
);
}
deps.io.out(sid);
}
if (readinessError) return EXIT.error;
if (dead) return EXIT.dead;
return ready === false ? EXIT.timeout : EXIT.ok;
}
export interface SendOptions {
id: string;
text: string;
enter: boolean;
/** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */
wait?: string | true;
timeoutMs?: number;
clientId?: string;
seq?: number;
}
/** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */
export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) {
return fail(deps, `refusing: ${options.id} is me — typing into my own composer is not a message`, EXIT.refused);
}
const refusal = inputRefusal(options.text);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const body = buildSendBody(options.text, {
enter: options.enter,
clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
seq: options.seq ?? (deps.now ?? Date.now)(),
wait: options.wait,
waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined,
});
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult }
| undefined;
if (deps.json) emitJson(deps, data ?? {});
// Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server
// holds the chunk and types it once the pane is back (`buffered`), or the chunk was
// over the wake buffer's cap and is gone (`dropped`). The seq is spent either way,
// so a retry needs a new one (the default, the clock, gives it that).
if (data?.dropped) {
if (!deps.json) {
deps.io.err(
palette.err(
`${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back`
)
);
}
return EXIT.error;
}
// `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
// the PTY exited or send-keys hit a dead pane. The field exists so a client does not
// say "wait longer" when the truth is "restart the worker" — so it is a failure here.
if (data?.delivered === false && !data.duplicate) {
if (!deps.json) {
deps.io.err(
palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`)
);
}
return EXIT.dead;
}
if (!deps.json) {
const noEnter = options.enter ? '' : ' (no Enter)';
if (data?.duplicate) {
deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
} else if (data?.buffered) {
deps.io.out(
palette.ok(
`${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back`
)
);
} else if (data?.delivered === true) {
deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
} else {
// Fire-and-forget answers before the write, so there is no delivery report here.
deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`));
}
if (data?.wait) deps.io.out(describeWait(data.wait));
}
if (options.wait === undefined) return EXIT.ok;
return waitExitCode(data?.wait);
}
function describeWait(wait: WaitResult): string {
if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`);
if (wait.ended) {
return palette.err(
`${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)`
);
}
// A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it,
// so the line stays neutral instead of looking like an error to whoever reads the log.
if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`);
if (wait.matched !== undefined) {
return wait.matched
? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`)
: palette.muted('not matched (exit 2)');
}
return palette.ok(
`${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}`
);
}
export interface WaitOptions {
id: string;
until?: string;
match?: string;
from?: 'buffer' | 'now';
fresh?: boolean;
nocase?: boolean;
timeoutMs: number;
}
/** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */
export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<number> {
if (options.until && options.match)
return fail(deps, 'use either --until <signals> or --match <marker>, not both', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
const res = options.match
? await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait-output`,
query: {
match: options.match,
from: options.from ?? 'buffer',
nocase: options.nocase ? 1 : undefined,
timeout: options.timeoutMs,
},
timeoutMs: options.timeoutMs + 10_000,
})
: await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait`,
query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A 400 here is the server saying "this mode has no such signal" (until=stop on an
// external CLI). Passed through, never papered over: the marker path is the answer.
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined;
if (deps.json) {
emitJson(deps, data ?? {});
} else if (data?.wait) {
deps.io.out(describeWait(data.wait));
if (data.limitPaused)
deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`));
}
return waitExitCode(data?.wait);
}
export interface ReadOptions {
id: string;
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
tail?: number;
/** Whole conversation (`context=full`) instead of the last assistant message. */
full?: boolean;
}
/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise<number> {
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
if (options.tail !== undefined) {
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/terminal`,
query: { tail: options.tail },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? '';
if (deps.json) emitJson(deps, res.json.data);
else deps.io.out(stripAnsi(buffer));
return EXIT.ok;
}
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/last-response`,
query: { context: options.full ? 'full' : undefined },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> }
| undefined;
if (deps.json) {
emitJson(deps, data ?? {});
return EXIT.ok;
}
if (options.full && data?.messages) {
for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`);
return EXIT.ok;
}
const text = data?.text ?? '';
if (!text) {
deps.io.err(
palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)')
);
return EXIT.ok;
}
deps.io.out(text);
return EXIT.ok;
}
/** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */
export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else
deps.io.out(
palette.ok(
`${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt`
)
);
return EXIT.ok;
}
/** `agent rm` — delete a session that is provably not this one. */
export async function agentRm(deps: AgentDeps, options: { id: string }): Promise<number> {
const refusal = deleteRefusal(deps.ctx.selfId, options.id);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
// The guard again on the RESOLVED id: a prefix that is not me can still resolve
// to me only if the list is lying, but a delete is the one call worth the paranoia.
const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
const res = await deps.request(deps.ctx, {
method: 'DELETE',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commander wiring
// ─────────────────────────────────────────────────────────────────────────────
const DEFAULT_WAIT_MS = 60_000;
/** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */
function liveDeps(json: boolean): AgentDeps | undefined {
try {
return {
ctx: resolveAgentContext(),
request: httpRequest,
json,
io: { out: (line) => console.log(line), err: (line) => console.error(line) },
};
} catch (err) {
if (err instanceof AgentGuardError) {
console.error(palette.err(`${GLYPH.fail} ${err.message}`));
return undefined;
}
throw err;
}
}
/** Run a verb with the live transport and turn its exit code into the process exit. */
async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): Promise<void> {
const deps = liveDeps(json);
if (!deps) {
process.exitCode = EXIT.refused;
return;
}
try {
process.exitCode = await verb(deps);
} catch (err) {
console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
process.exitCode = EXIT.error;
}
}
/** Register `codeman agent …` on the program. */
export function registerAgentCommands(program: Command): Command {
const agent = program
.command('agent')
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
agent
.command('ls')
.alias('list')
.description('List sessions; * marks this one')
.option('--json', 'Machine-readable output')
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
agent
.command('spawn <case>')
.description(
'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
)
.option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude')
.option('-n, --name <name>', 'Session name shown in the UI')
.option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
.option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentSpawn(deps, {
caseName,
mode: options.mode,
name: options.name,
ready: options.ready,
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
})
)
);
agent
.command('send <id> <text...>')
.description(
'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send <id> -- "- fix the bug")'
)
.option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
.option('-u, --until <signals>', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
.option('--no-enter', 'Type the text without submitting it')
.option('--client-id <id>', 'Exactly-once tag (default: one per calling session)')
.option(
'--seq <n>',
'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed'
)
.option('--json', 'Machine-readable output')
.action(
(
id: string,
words: string[],
options: {
wait?: boolean;
until?: string;
timeout?: string;
enter: boolean;
clientId?: string;
seq?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) => {
const prompt = sendPromptFromArgs(words);
if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused));
return agentSend(deps, {
id,
text: prompt.text,
enter: options.enter,
wait: options.until ?? (options.wait ? true : undefined),
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
clientId: options.clientId,
seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'),
});
})
);
agent
.command('wait <id>')
.description(
'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
)
.option(
'-u, --until <signals>',
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
)
.option(
'-m, --match <marker>',
'Literal substring to wait for in the output (ANSI-stripped, no regex). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)'
)
.option('--from <where>', 'buffer (scan existing output first, the default) or now', 'buffer')
.option('--nocase', 'Case-insensitive --match')
.option('--fresh', 'Require an actual transition (--until only)')
.option('-t, --timeout <ms>', 'Budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(
id: string,
options: {
until?: string;
match?: string;
from: string;
nocase?: boolean;
fresh?: boolean;
timeout?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) =>
agentWait(deps, {
id,
until: options.until,
match: options.match,
from: options.from === 'now' ? 'now' : 'buffer',
nocase: options.nocase,
fresh: options.fresh,
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
})
)
);
agent
.command('read <id>')
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
.option('--tail <bytes>', 'Raw terminal tail in bytes, ANSI stripped (works in every mode)')
.option('--full', 'The whole conversation instead of the last assistant message')
.option('--json', 'Machine-readable output')
.action((id: string, options: { tail?: string; full?: boolean; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentRead(deps, {
id,
tail: options.tail === undefined ? undefined : parsePositiveInt(options.tail, 3000, '--tail'),
full: options.full,
})
)
);
agent
.command('interrupt <id>')
.description('Send a bare ESC to stop the current turn (the conversation survives; deleting would not)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
);
agent
.command('rm <id>')
.description('Delete any session except this one (refuses your own id)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
return agent;
}
+28 -8
View File
@@ -15,11 +15,9 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { registerAgentCommands } from './cli-agent.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
@@ -44,8 +42,32 @@ function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
function readCodemanEnv(): Record<string, string> {
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
} catch {
return {};
}
}
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
const { username, password } = readCodemanCredentials();
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
@@ -230,10 +252,6 @@ skillCmd
}
});
// ============ Agent Commands (session-to-session, any CLI mode) ============
registerAgentCommands(program);
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
@@ -623,7 +641,9 @@ function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
} catch {
return Promise.resolve(null);
}
const { username, password } = readCodemanCredentials();
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const transport = url.protocol === 'https:' ? https : http;
const headers: Record<string, string> = { Accept: 'application/json' };
if (password) {
-75
View File
@@ -1,75 +0,0 @@
/**
* @fileoverview Credentials for a client of this Codeman instance's own API.
*
* Env first, the data dir's `.env` as the fallback — the hand-authored file
* `codeman attach`, `codeman tui` and `codeman agent` all read. One reader, so the
* three clients cannot drift on quoting, comments or the default username.
*
* @module codeman-credentials
*/
import { readFileSync } from 'node:fs';
import { dataPath } from './config/instance.js';
export interface CodemanCredentials {
username: string;
/** Absent when no password is configured (or only the server's environment has it). */
password?: string;
}
/**
* Parse a `KEY=value` env file: blank lines and `#` comments skipped, an `export `
* prefix tolerated (the file is hand-authored, often sourced by a shell too), one
* layer of matching quotes stripped, anything that is not an assignment ignored.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
/** The data dir's `.env`, parsed. Absent or unreadable means `{}`. */
export function readCodemanEnvFile(envFilePath: string = dataPath('.env')): Record<string, string> {
try {
return parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
return {};
}
}
/**
* The lookup order every client uses, per field: the environment, then the `.env`
* file, then (username only) `admin`. Pure, so a caller with its own environment
* object (`codeman agent`'s guard takes one for testability) gets the same answer.
*/
export function credentialsFrom(env: NodeJS.ProcessEnv, fileEnv: Record<string, string>): CodemanCredentials {
const username = env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
/**
* Credentials for the API. No password means no auth is configured, or the user has
* it only in the server's environment, in which case the API answers 401.
*/
export function readCodemanCredentials(
envFilePath: string = dataPath('.env'),
env: NodeJS.ProcessEnv = process.env
): CodemanCredentials {
return credentialsFrom(env, readCodemanEnvFile(envFilePath));
}
/** `Authorization` header value, or undefined when there is no password to send. */
export function basicAuthHeader(credentials: CodemanCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
-3
View File
@@ -320,8 +320,6 @@ const capabilitiesSchema = z
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
// Literal text matched by a `wait-output` long-poll, never compiled as a regex.
composerReadyMark: z.string().min(1).max(64).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
@@ -451,7 +449,6 @@ const capabilitiesSchema = z
'codex-toml',
'opencode-json',
'antigravity-json',
'copilot-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
-5
View File
@@ -218,9 +218,6 @@ const CLAUDE: CliEntry = {
// in them, so a copy can drop two and paste flush. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
// The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
// trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
composerReadyMark: 'shift+tab',
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
// footer, because tmux repaints partially and only one of the two may land in a chunk.
@@ -1345,8 +1342,6 @@ const DEEPSEEK: CliEntry = {
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
// the session can disarm the bridge, and docker/remote cannot reach it at all.
hooks: 'supervised',
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
composerReadyMark: '❯',
transcript: 'deepseek-zstd',
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
+1 -18
View File
@@ -105,13 +105,7 @@ export type ModelConfigResolverName = 'deepseek-route';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat =
| 'claude-json'
| 'gemini-json'
| 'codex-toml'
| 'opencode-json'
| 'antigravity-json'
| 'copilot-json';
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
export interface CliLaunch {
params: Record<string, ParamSpec>;
@@ -422,17 +416,6 @@ export interface CliCapabilities {
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
/**
* Literal text the TUI draws once its composer can take a prompt — what `codeman agent
* spawn` waits for (a `wait-output` match) before it calls a worker ready.
*
* Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
* of its workspace-trust dialog, which is exactly the screen a readiness wait must not
* mistake for a composer, so claude declares its composer's own hint text instead.
* Absent means no readiness wait: a spawn returns as soon as the session exists, and
* the caller synchronizes on `wait-output` markers.
*/
composerReadyMark?: string;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
-4
View File
@@ -495,10 +495,6 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
SSH_ASKPASS_REQUIRE: 'never',
DISPLAY: '',
GCM_INTERACTIVE: 'never',
// classifyGitFailure() matches git's ENGLISH stderr; a German or French locale would
// turn a missing ref into a generic FAILED (422 instead of 400).
LC_ALL: 'C',
LANG: 'C',
GIT_SSH_COMMAND:
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
};
+2 -6
View File
@@ -14,7 +14,6 @@ import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
// ========== Types ==========
@@ -158,15 +157,12 @@ export class ImageWatcher extends EventEmitter {
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
// Ignore common heavy directories for performance
ignored: (path: string) => {
// Skip node_modules, .git, and other heavy directories, and Codeman's
// own upload folders: a pdf the user handed to the agent is not a file
// the agent produced.
// Skip node_modules, .git, and other heavy directories
if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
path.includes('/.next/') ||
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
path.includes('/.next/')
) {
return true;
}
-74
View File
@@ -1,74 +0,0 @@
/**
* @fileoverview MCP sync targets that are not Codeman run modes.
*
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
* config file already exists (an absent tool is reported `absent`, never created), and sort after
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
*
* @module mcp-sync-targets
*/
import { accessSync, constants as fsConstants } from 'node:fs';
import { homedir } from 'node:os';
import { delimiter, join } from 'node:path';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncTarget } from './mcp-sync.js';
export interface McpSyncOnlyTool {
id: string;
label: string;
/** Home-relative default location of the MCP config file. */
path: string;
format: McpConfigFormat;
/** The env var the tool reads to move its home, and the file under it. */
relocation?: { envVar: string; path: string };
/** The executable whose presence on this machine means the tool is installed. */
binary: string;
}
export const MCP_SYNC_ONLY_TOOLS: readonly McpSyncOnlyTool[] = [
{
id: 'copilot',
label: 'GitHub Copilot CLI',
path: '.copilot/mcp-config.json',
format: 'copilot-json',
// COPILOT_HOME replaces ~/.copilot (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
binary: 'copilot',
},
];
/** `name` is an executable file in the server's PATH, `~/.local/bin` or `/usr/local/bin`. */
export function binaryOnPath(name: string, env: Record<string, string | undefined> = process.env): boolean {
const dirs = [
...(env.PATH ?? '').split(delimiter).filter(Boolean),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
];
return dirs.some((dir) => {
try {
accessSync(join(dir, name), fsConstants.X_OK);
return true;
} catch {
return false;
}
});
}
/** The sync-only tools as sync targets, skipping any id the registry already provides. */
export function mcpSyncOnlyTargets(
taken: ReadonlySet<string>,
isInstalled: (binary: string) => boolean = binaryOnPath
): McpSyncTarget[] {
return MCP_SYNC_ONLY_TOOLS.filter((t) => !taken.has(t.id)).map((t) => ({
id: t.id,
label: t.label,
path: t.path,
format: t.format,
...(t.relocation ? { relocation: t.relocation } : {}),
installed: isInstalled(t.binary),
}));
}
+1 -63
View File
@@ -12,8 +12,7 @@
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
* `enabled: false`, antigravity `disabled: true`, Copilot's `disabledMcpServers` in its
* `settings.json`) is not propagated: copying it would
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
@@ -285,29 +284,6 @@ function toOpencode(s: McpServer): Record<string, unknown> {
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
/**
* GitHub Copilot CLI (`copilot mcp add`): `~/.copilot/mcp-config.json`, `mcpServers`. A stdio server is
* `type: "local"`; every entry carries `tools` (`["*"]` = all). Whether a server is switched off is NOT in
* this file: `copilot mcp disable` records the name in `settings.json` beside it (`disabledMcpServers`).
*/
function fromCopilot(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
if ((raw.type === 'http' || raw.type === 'sse') && typeof raw.url === 'string') {
return clean({ transport: raw.type, url: raw.url, headers: strMap(raw.headers) });
}
if ((raw.type === undefined || raw.type === 'local' || raw.type === 'stdio') && typeof raw.command === 'string') {
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
}
return null;
}
function toCopilot(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return { tools: ['*'], type: 'local', command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}) };
}
return { tools: ['*'], type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
}
interface JsonDialect {
/** Key holding the server table. */
key: string;
@@ -315,23 +291,12 @@ interface JsonDialect {
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
/**
* A file beside the config that lists the names of servers the user switched off (the switch is
* not stored on the server entry). Read, never written.
*/
disabledIn?: { file: string; key: string };
}
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'copilot-json': {
key: 'mcpServers',
from: fromCopilot,
to: toCopilot,
disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
},
'opencode-json': {
key: 'mcp',
from: fromOpencode,
@@ -593,32 +558,6 @@ function resolveFile(
return { file: join(dir, rel.path) };
}
/**
* 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
* reported unreadable rather than guessing: a guess could switch a server on everywhere.
*/
async function applyCompanionDisabled(format: McpFormat, file: string, servers: McpServerMap): Promise<void> {
if (format === 'codex-toml') return;
const companion = JSON_DIALECTS[format].disabledIn;
if (!companion) return;
const text = await readText(join(dirname(file), companion.file));
if (text === null || !text.trim()) return;
let doc: unknown;
try {
doc = JSON.parse(text);
} catch {
throw new McpConfigError(
`${companion.file} next to the config is not valid JSON, so which servers are switched off is unknown`
);
}
const list = isRecord(doc) ? doc[companion.key] : undefined;
if (list === undefined) return;
const names = strArr(list);
if (!names) throw new McpConfigError(`"${companion.key}" in ${companion.file} is not a list of names`);
for (const n of names) if (n in servers) servers[n] = { ...servers[n], disabled: true };
}
let applying = false;
/**
@@ -672,7 +611,6 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
+9
View File
@@ -204,6 +204,15 @@ 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.
* Absent when the pane could not be queried.
*/
capturedHistoryLines?: number;
}
/**
+15 -3
View File
@@ -700,6 +700,12 @@ interface PaneCursorGeometry {
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;
}
/**
@@ -716,7 +722,7 @@ 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] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -729,7 +735,9 @@ 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;
return geometry;
}
/** SGR attributes, which is all `capture-pane -e` emits. */
@@ -3890,7 +3898,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// 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}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
@@ -3902,6 +3910,10 @@ 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.
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
+47 -9
View File
@@ -48,12 +48,6 @@ import https from 'node:https';
import { hostname as osHostname } from 'node:os';
import { promisify } from 'node:util';
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
import {
basicAuthHeader,
parseEnvFile,
readCodemanCredentials,
type CodemanCredentials,
} from '../codeman-credentials.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { probeServer } from '../daemon-control.js';
import { getErrorMessage } from '../types/api.js';
@@ -287,9 +281,53 @@ export function tuiServerCandidates(env: { apiUrl?: string; port?: string | numb
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
}
// One credential reader for every client of the API (attach, tui, agent).
export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
export type TuiCredentials = CodemanCredentials;
/**
* Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
* lines and `#` comments skipped, one layer of matching quotes stripped.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
export interface TuiCredentials {
username: string;
password?: string;
}
/**
* Credentials for the API, env first and the data dir's `.env` as the fallback,
* exactly like the `codeman attach` path. No password means no auth is
* configured (or the user has it only in the server's environment, in which
* case the API answers 401 and `connect()` reports `authRequired`).
*/
export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
let fileEnv: Record<string, string> = {};
try {
fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
/* absent or unreadable: env-only */
}
const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Degraded mode
+30 -105
View File
@@ -1,99 +1,25 @@
/**
* @fileoverview Periodic GC for prompt-upload files, and the one place that
* names the directories they live in.
* @fileoverview Periodic GC for paste-image files.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
* under {workingDir}/.codeman-uploads/. The route only triggers cleanup on
* under {workingDir}/.claude-images/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
* older than MAX_AGE_MS from each live session's upload dirs on an interval.
* older than MAX_AGE_MS from each live session's image dir on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
* upload dir.
* image dir.
*/
import fs from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { join, resolve, sep } from 'node:path';
import { getDataDir } from '../config/instance.js';
import { probePathKind, type PathProbeOptions } from '../utils/bounded-path-probe.js';
import { join, resolve } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
/**
* Where a prompt upload is written, relative to the session's working
* directory. 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); hidden, so it stays out of
* `git status` and the agent's view of the repository; FLAT, because a nested
* `<workspace>/.codeman/` is Codeman's own data dir when the workspace is the
* home directory; and self-ignoring, through a `.gitignore` of `*` the route
* writes once.
*/
export const UPLOADS_DIR = '.codeman-uploads';
/** Where uploads landed before the move: written to by nothing, readable for one release. */
export const LEGACY_UPLOADS_DIR = '.claude-images';
/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
/**
* Every directory a session's uploads sit in, current first. Both consumers
* act on what this returns, the hourly sweep and the recursive delete in
* cleanupSession(), so it lists only REAL directories (a link planted by a
* workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is
* not one; readdir follows a link to a directory), none that is or contains
* this instance's data dir (a home workspace reaches it under a contrived
* instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` can point
* inside one; a directory strictly below the data dir only ever holds uploads
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
* never be collected), and nothing for a remote (SSH) session, whose
* workingDir is the remote path and would name a same-named LOCAL directory
* here. The working directory is a user-chosen path, so it is probed BOUNDED
* first (#516): one on a mount that stopped answering reads `unknown` and is
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
* acting on one path at the user's request, passes `pastCap`. The check is
* made when listing: a same-user process that swaps a listed directory for a
* link afterwards is accepted, since it already writes anywhere this process
* can.
*/
export async function uploadDirs(
session: { workingDir: string; remote?: unknown },
probe: PathProbeOptions = {}
): Promise<string[]> {
if (session.remote) return [];
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
const dataDir = await realDir(getDataDir());
const dirs: string[] = [];
for (const dir of UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name))) {
if (!(await isRealDir(dir))) continue;
const real = await realDir(dir);
if (real === dataDir || dataDir.startsWith(real + sep)) continue;
dirs.push(dir);
}
return dirs;
}
/** lstat, so a symlink is not a directory, whatever it points at. */
async function isRealDir(p: string): Promise<boolean> {
try {
return (await fs.lstat(p)).isDirectory();
} catch {
return false;
}
}
/** `canonicalDir()` for the listing, which must not block the event loop on a user path. */
async function realDir(dir: string): Promise<string> {
try {
return await fs.realpath(dir);
} catch {
return resolve(dir);
}
}
export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now()
@@ -102,27 +28,26 @@ export async function sweepPasteImagesOnce(
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
for (const dir of await uploadDirs(session)) {
let entries: string[];
const dir = join(session.workingDir, '.claude-images');
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir absent — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
entries = await fs.readdir(dir);
} catch {
continue; // gone since listed — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
}
}
}
@@ -130,7 +55,7 @@ export async function sweepPasteImagesOnce(
}
/**
* The path two sessions must share to share an upload dir: the canonical
* The path two sessions must share to share a paste-image dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
@@ -143,7 +68,7 @@ function canonicalDir(dir: string): string {
}
}
/** One session the upload-dir guard weighs: its id, directory and, for a persisted record, its status. */
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
@@ -151,11 +76,11 @@ export interface PasteImageDirUser {
}
/**
* Does another live session still use this working directory's upload dirs?
* Deleting a session removes them (`uploadDirs()`) recursively, and several
* sessions routinely share one case directory, so without this check closing
* one session deletes the pasted images a sibling in the same case still
* refers to.
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
*
* Two kinds of sibling count as live:
*
+58 -280
View File
@@ -717,7 +717,6 @@ class CodemanApp {
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this._tabRailSearch = ''; // rail search box text: in memory only, never persisted
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -1427,13 +1426,6 @@ class CodemanApp {
this.closeTileCountMenu({ refocus: true });
return;
}
// And so does the rail's search box while it holds text: that Escape
// clears the search and nothing else. This listener runs in the capture
// phase, before the box's own onkeydown, so the box cannot claim it there.
if (e.target?.id === 'tabRailSearch' && this._tabRailSearch) {
this.handleTabRailSearchKeydown(e);
return;
}
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
@@ -1667,12 +1659,8 @@ 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, placement = null) {
detachSession(id) {
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
@@ -1700,7 +1688,7 @@ class CodemanApp {
this._postWindowMessage({ type: 'detached', id });
return;
}
const features = this._detachWindowFeatures(placement);
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
if (!win) {
@@ -1714,21 +1702,6 @@ 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
@@ -1839,11 +1812,8 @@ class CodemanApp {
_markDetached(id, on) {
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
// A popped-out session's window owns its PTY size now, so it leaves the
// tile grid (one place per session in this browser tab). `gone`: it left by
// itself, not by a tile the user removed, so the grid's count stays and the
// ranking fills that cell the next time the grid opens, as when it pops out
// with the grid closed.
if (on && this._tileGrid?.has(id)) this.removeTile(id, { gone: true });
// tile grid (one place per session in this browser tab).
if (on && this._tileGrid?.has(id)) this.removeTile(id);
const container = this.$('sessionTabs');
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.classList.toggle('detached', on);
@@ -1897,15 +1867,11 @@ class CodemanApp {
if (!msg || typeof msg !== 'object') return;
if (this.isSoloWindow) {
// Roll-call has no id (broadcast to all) — answer before the id filter.
// 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.type === 'roll-call') { 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._releaseSoloWindow(); }
if (msg.type === 'close-request') { this._closeSoloWindow(); }
else if (msg.type === 'focus-request') {
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
}
@@ -1913,10 +1879,6 @@ 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
@@ -1926,9 +1888,6 @@ 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);
}
}
@@ -1975,41 +1934,6 @@ 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() {
@@ -2025,8 +1949,6 @@ 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.
@@ -2815,23 +2737,6 @@ class CodemanApp {
return;
}
// An in-document link (`[Install](#installation)`). The browser must not follow it: with
// `<base href="/">` a bare fragment points at the dashboard's root and would navigate the
// app away. Resolve it inside this rendered document and scroll there (constants.js).
// A fragment that matches nothing is simply ignored, never a navigation.
const fragmentLink = ev.target.closest('a[href^="#"]');
if (fragmentLink && body.contains(fragmentLink)) {
ev.preventDefault();
ev.stopPropagation();
const root = fragmentLink.closest('.rv-text') || body;
const target = window.CodemanMarkdownAnchors?.find(root, fragmentLink.getAttribute('href'));
if (target) {
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
}
return;
}
// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
// (webview-tabs.js). Every other link keeps its new-tab default.
@@ -5838,163 +5743,27 @@ class CodemanApp {
*/
applySidebarFilter(query) {
this._sidebarFilter = (query ?? '').trim().toLowerCase();
this._applyTabListFilter();
}
/**
* The ONE row filter behind both search boxes: the sidebar's filter box and
* the vertical rail's search box (only one of the two hosts the list at a
* time). Classes only, over whatever the last render drew, so grouping, order,
* Alt+N badges and the server layout never move; the matching itself is the
* pure CodemanTabSearch (constants.js).
*
* - Sidebar: name (aria-label) + working directory (title), as it always has.
* - Rail: the NAME only, a web tab's title included (it is a row in the same
* list, and hiding every web tab would make a dashboard unfindable).
*
* A session row with a tab alert (red action or yellow idle, whatever
* tabAlerts holds, the set a collapsed group header surfaces) stays visible
* even when it does not match: a prompt waiting on you is never hidden by a
* view filter. Alerts come and go through renderSessionTabs(), and both
* render paths end here, so nothing else re-runs this for them.
*
* A group or case box left with nothing showing hides with its header, its
* count shows the rows left showing (a kept row included), and the grouped
* tree's roving stop and posinset follow the visible items. A collapsed
* group's rows are not in the DOM at all, which is why the rail search also
* expands the projection (_projectTabGroups). Rows that appear or disappear
* move the rows below them, so the connector lines are redrawn then.
*/
_applyTabListFilter() {
const container = this.$('sessionTabs');
if (!container) return;
const rail = this._tabOrientation() === 'vertical';
const sidebarReachable =
!rail && this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const query = rail ? this._tabRailSearch : sidebarReachable ? this._sidebarFilter : '';
const rows = [...container.querySelectorAll('.session-tab')].map((tab) => ({
key: tab,
text: rail
? this._tabRowSearchName(tab)
: `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`,
section: tab.closest('.tab-layout-group, .tab-cluster'),
// Web tabs carry no alerts; only a session row can be kept.
keep: !tab.dataset.webviewId && !!tab.dataset.id && !!this.tabAlerts?.get(tab.dataset.id),
}));
const result = window.CodemanTabSearch?.filter(rows, query);
if (!result) return;
// Whether anything appeared or disappeared: the rows below it then moved.
let moved = false;
const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : '';
// State headings count the whole group, so they step aside while a filter
// is narrowing the rows under them (styles.css, .tabs-filtering).
if (container.classList.contains('tabs-filtering') !== result.active) {
container.classList.toggle('tabs-filtering', result.active);
moved = true;
}
const setFilteredOut = (el, out) => {
if (el.classList.contains('tab-filtered-out') === out) return;
el.classList.toggle('tab-filtered-out', out);
moved = true;
};
for (const row of rows) setFilteredOut(row.key, result.hidden.has(row.key));
for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) {
const shown = result.counts.get(section) ?? 0;
setFilteredOut(section, result.active && shown === 0);
const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count');
if (!count) continue;
if (count.dataset.total === undefined) count.dataset.total = count.textContent;
const text = result.active ? String(shown) : count.dataset.total;
if (count.textContent !== text) count.textContent = text;
}
const empty = document.getElementById('tabRailSearchEmpty');
const emptyHidden = !(rail && result.active && result.matchCount === 0);
if (empty && empty.hidden !== emptyHidden) {
empty.hidden = emptyHidden;
moved = true;
}
// Lineage and subagent/ultracode connectors are anchored to row positions.
// A render redraws them itself, but a keystroke in either box only toggles
// classes here, so the rows it moved would leave the lines pointing at where
// they were. Only when something moved: an unchanged re-apply at every
// render tail stays free, and the call coalesces with a render's own.
if (moved) this.updateConnectionLines?.();
// Both render paths already set posinset and the roving stop over an
// unfiltered tree, so this second pass only runs while a search hides
// something or right after one changed what shows.
if ((moved || result.active) && container.getAttribute('role') === 'tree') {
const items = this._applyTabTreePositions(container);
const stop = container.querySelector('[role="treeitem"][tabindex="0"]');
if (items.length && !items.includes(stop)) {
this._setTabTreeStop(container, items.find((item) => item.getAttribute('aria-selected') === 'true') || items[0]);
container.classList.toggle('tabs-filtering', !!needle);
for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) {
tab.classList.remove('tab-filtered-out');
continue;
}
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
}
// The count shows visible rows, so it moves with every filter change —
// including keystrokes in the filter box, which call this directly.
this.updateSidebarCount();
}
/** What the rail search matches on a row: a session's name, a web tab's title. */
_tabRowSearchName(tab) {
if (tab.dataset.webviewId) return this.webviews?.get(tab.dataset.webviewId)?.name || '';
return tab.querySelector('.tab-name')?.dataset.fullName || '';
}
/** True while the vertical rail's search box is narrowing the list. */
_tabRailSearchActive() {
return this._tabOrientation() === 'vertical' && !!window.CodemanTabSearch?.needle(this._tabRailSearch);
}
/**
* The rail search box's input handler. In-memory only: never persisted, never
* sent anywhere. Starting or ending a search re-renders once when it changes
* what a collapsed group hides (the projection ignores collapse while
* searching); every other keystroke only re-applies the row classes.
*/
setTabRailSearch(value) {
this._tabRailSearch = typeof value === 'string' ? value : '';
const clear = document.getElementById('tabRailSearchClear');
if (clear) clear.hidden = this._tabRailSearch.length === 0;
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
else this._applyTabListFilter();
}
/** Clear button (and Escape): empty the box, restore the list, keep focus in the box. */
clearTabRailSearch() {
const input = document.getElementById('tabRailSearch');
if (input) input.value = '';
this.setTabRailSearch('');
input?.focus();
}
/**
* Escape in a box that holds text clears the search and nothing else. The
* global key handler (setupEventListeners) runs in the CAPTURE phase, before
* the box's inline onkeydown, so it is the one that routes the key here and
* returns before its close-every-panel branch; stopping propagation from the
* inline handler would come too late. An empty box leaves Escape to it, and
* an Escape that cancels an IME composition is the IME's.
*/
handleTabRailSearchKeydown(event) {
if (event.key !== 'Escape' || event.isComposing || !this._tabRailSearch) return;
event.preventDefault();
event.stopPropagation();
this.clearTabRailSearch();
}
/**
* Forget the search without rendering: the list is leaving the rail
* (applyTabOrientation), and the render that follows draws it unfiltered.
*/
_resetTabRailSearch() {
this._tabRailSearch = '';
const input = document.getElementById('tabRailSearch');
if (input) input.value = '';
const clear = document.getElementById('tabRailSearchClear');
if (clear) clear.hidden = true;
const empty = document.getElementById('tabRailSearchEmpty');
if (empty) empty.hidden = true;
}
// ═══════════════════════════════════════════════════════════════
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
// ═══════════════════════════════════════════════════════════════
@@ -6367,20 +6136,6 @@ class CodemanApp {
};
}
/**
* The session's tab row when it is painted, else null: not rendered (a
* collapsed group) or hidden by the rail search or the sidebar filter. A
* display:none row still answers getBoundingClientRect() with an all-zero
* rect, which is truthy, so a connector, a spawn or a genie measured from it
* would start at the viewport's top-left corner. Every floating window that
* anchors to its parent tab measures through this.
*/
_paintedSessionTab(sessionId) {
if (!sessionId) return null;
const tab = document.querySelector(`.session-tab[data-id="${sessionId}"]`);
return tab && tab.getClientRects().length > 0 ? tab : null;
}
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
_tabConnectorPath(anchor, winRect) {
if (anchor.vertical) {
@@ -6969,9 +6724,7 @@ class CodemanApp {
// every agent CLI, claude included, shows its logo through PR #532's
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
// headers and the Run menus draw the same mark. An id with no logo rule (a
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
const tabModeHtml = mode === 'shell'
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
@@ -7227,8 +6980,6 @@ class CodemanApp {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
// A group the search emptied is hidden whole, header included.
if (section.classList.contains('tab-filtered-out')) continue;
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
@@ -7632,10 +7383,7 @@ class CodemanApp {
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
// A rail search shows matches inside collapsed groups too, so it projects
// every group open. The stored per-device collapse state is untouched and
// applies again as soon as the search is cleared.
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
collapsedGroupIds: [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
@@ -7656,9 +7404,6 @@ class CodemanApp {
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
// Every group is drawn open while the rail search runs; a toggle then would
// change what the user sees only after the search is cleared.
if (this._tabRailSearchActive()) return false;
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
@@ -8944,6 +8689,9 @@ class CodemanApp {
// 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();
}
@@ -8951,9 +8699,27 @@ class CodemanApp {
/** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId);
this._historyNoticeDismissed?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/**
* 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.
*
@@ -8963,13 +8729,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;
}
@@ -9002,6 +8777,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);
@@ -9115,7 +8893,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
@@ -10135,10 +9915,8 @@ class CodemanApp {
try {
await this._apiDelete('/api/sessions');
// Every tiled session is gone: nothing to reselect. The stored grid is
// kept like every other close; it now names only gone sessions, so the
// next Tiles click ranks the open sessions from scratch.
this.closeTileGrid?.({ keepStored: true, reselect: false });
// Every tiled session is gone: nothing left to remember or reselect.
this.closeTileGrid?.({ keepStored: false, reselect: false });
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
+55 -295
View File
@@ -1020,56 +1020,6 @@ function tabClusterNameSplit(name, label) {
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
}
/**
* Session-list search: the vertical rail's search box and the sidebar's filter
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
* Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
* Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
* @param {unknown} query
* @returns {string} the needle, '' when there is nothing to search for
*/
function tabSearchNeedle(query) {
return typeof query === 'string' ? query.trim().toLowerCase() : '';
}
/**
* Which rows a search hides. Pure: the caller reads the rows off the list it
* rendered and applies the result as classes, so the list itself (grouping,
* order, Alt+N badges) is never rebuilt or reordered by a search.
*
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
* view filter). It counts toward its section, so its group stays on screen with
* it, but not toward `matchCount`.
*
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
* in list order; `section` is the row's group or case box, null/undefined for none.
* @param {unknown} query
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
* `counts` is the rows left showing per section (kept rows included), every
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
*/
function filterTabSearchRows(rows, query) {
const needle = tabSearchNeedle(query);
const hidden = new Set();
const counts = new Map();
let matchCount = 0;
for (const row of Array.isArray(rows) ? rows : []) {
const hasSection = row.section !== null && row.section !== undefined;
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
const matches = !needle || text.includes(needle);
if (!matches && row.keep !== true) {
hidden.add(row.key);
continue;
}
if (matches) matchCount++;
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
}
return { active: needle.length > 0, hidden, counts, matchCount };
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1351,63 +1301,6 @@ function cleanCopiedSelection(text, options) {
return lines.join('\n');
}
// ── Markdown heading anchors ────────────────────────────────────────────────
// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
// nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
/**
* GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
* dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
*/
function markdownHeadingSlug(text) {
return String(text ?? '')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
.replace(/ /g, '-');
}
/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
function assignMarkdownHeadingAnchors(root) {
const used = new Set();
for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const base = markdownHeadingSlug(heading.textContent);
let slug = base;
for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
used.add(slug);
heading.dataset.mdAnchor = slug;
}
}
/**
* The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
* points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
* slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
* only (never `document.getElementById`, which could find an app element of the same name).
*/
function findMarkdownAnchorTarget(root, href) {
let fragment = String(href ?? '').replace(/^#/, '');
try {
fragment = decodeURIComponent(fragment);
} catch {
/* a malformed escape: use it as written */
}
if (!fragment) return root;
assignMarkdownHeadingAnchors(root);
const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
for (const heading of root.querySelectorAll('[data-md-anchor]')) {
if (wanted.includes(heading.dataset.mdAnchor)) return heading;
}
for (const el of root.querySelectorAll('[id]')) {
if (el.id === fragment) return el;
}
return null;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -1465,10 +1358,6 @@ if (typeof window !== 'undefined') {
groupFor: tabTriageGroupFor,
layout: computeTabTriageLayout,
};
window.CodemanTabSearch = {
needle: tabSearchNeedle,
filter: filterTabSearchRows,
};
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1481,11 +1370,6 @@ if (typeof window !== 'undefined') {
window.CodemanCopySelection = {
clean: cleanCopiedSelection,
};
window.CodemanMarkdownAnchors = {
slug: markdownHeadingSlug,
assign: assignMarkdownHeadingAnchors,
find: findMarkdownAnchorTarget,
};
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
@@ -1860,7 +1744,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 +1761,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}} 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 +1804,15 @@ 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.
const more =
historyLines !== null
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
: `${formatHistoryBytes(dropped)} more 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,
};
}
@@ -2202,11 +2110,11 @@ function tileGridCapacity({ width, height }) {
}
/**
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
* content) made safe to apply: unknown, deleted, detached and duplicate ids
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
* Anything that is not a v1 object (or its JSON) gives null.
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
* deleted, detached and duplicate ids are dropped, the list is capped at
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
* (or its JSON) gives null.
*
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
@@ -2214,20 +2122,11 @@ function tileGridCapacity({ width, height }) {
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
*
* `freed` names the cells whose session no longer exists (or was popped out
* to its own window) since the grid was stored: the ranking fills those first
* when the grid comes back (restoreTileGridCells). A hole the user left empty
* is not freed. `count` is how many tiles the grid had after the user's own
* last change (a session that went away by itself does not lower it), so the
* grid comes back to that many when there are sessions to fill it; a value
* stored before it existed, or a malformed one, reads as the number of
* sessions the stored cells name.
*
* @param {unknown} raw - the parsed value, or the stored JSON string
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
*/
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
let value = raw;
@@ -2238,16 +2137,11 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
const ids = [];
const cells = [];
const freed = [];
const named = [];
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
const isId = typeof id === 'string' && id !== '';
const present = isId && live.has(id) && !detachedIds?.has?.(id);
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
const keep =
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
ids.length < TILE_GRID_MAX;
if (keep) ids.push(id);
// Its session went away since: the cell is freed for the ranking to fill.
if (isId && !present && !named.includes(id)) freed.push(cells.length);
if (isId && !named.includes(id)) named.push(id);
// A malformed entry (not a string, not null) is a hole too.
cells.push(keep ? id : null);
}
@@ -2255,17 +2149,11 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
};
const count =
Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
? value.count
: Math.min(named.length, TILE_GRID_MAX);
return {
v: 1,
open: value.open === true && ids.length > 0,
ids,
cells,
freed,
count,
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
colFr: fractions(value.colFr),
@@ -2273,53 +2161,6 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
};
}
/**
* A stored grid as it comes back (the Tiles button, a page reload): its cells
* exactly as stored, holes the user left included, and its focus (or the tile
* it had zoomed). Only when it holds fewer tiles than its `count` (sessions
* that went away since, or by themselves while it was open) does it fill, from
* `ranked` (best first, never a session already in it): the freed cells first,
* then the other empty cells, in reading order; more than the cells hold join
* after them (the shape grows when the grid lays them out, reformTileCells).
* A cell stays empty only when no other session is left to place. Never
* trimmed to the window: a grid larger than the window fits shows its focused
* tile alone until the window fits it again, and the arrangement stays.
*
* @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
* focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
* @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
* @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
*/
function restoreTileGridCells(stored, ranked) {
const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
const cells = [];
for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
const tiles = cells.filter(Boolean);
if (tiles.length === 0) return null;
const focusedId =
[stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
const fillers = [];
for (const id of ranked || []) {
if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
}
const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
const empty = [];
cells.forEach((id, k) => {
if (id === null) empty.push(k);
});
// Freed cells first, each group in reading order.
empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
let count = tiles.length;
for (const k of empty) {
if (count >= target || fillers.length === 0) break;
cells[k] = fillers.shift();
count++;
}
const extra = fillers.slice(0, Math.max(0, target - count));
return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
}
/**
* New track fractions after a divider drag (grid-template `fr` values): the two
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
@@ -2352,8 +2193,7 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
/**
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
* ones a count fills a grid with), in the order given (tab order, or the
* ranking): live ones only, never a session popped out to
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
* its own window (that window owns its PTY size). A session with no PTY
* attached IS offered: its tile shows the Attach overlay.
*
@@ -2367,121 +2207,44 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
for (const id of sessionOrder) {
if (detachedIds?.has?.(id)) continue;
const session = sessions.get(id);
if (!session || result.some((r) => r.id === id)) continue;
if (!session) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
// Ranking groups (rankTileSessions): working first, then the sessions waiting
// on the user, then everything else.
const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
/**
* The stamp a session is ranked by inside its group. A WORKING session keys
* off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
* about once a second, so its last-activity stamp is always "now", and one
* that never submitted would otherwise claim the head of the group. 0 means
* unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
* the last byte the pane printed, i.e. when it went quiet).
*/
function tileRankStamp(row) {
if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
return sessionActivityAnchor(row);
}
/**
* Which open sessions the tile grid shows when nobody said which (the Tiles
* button with no stored grid to bring back, and every place the grid fills a
* tile on its own: a count picked in its menu, a freed cell), best first
* (owner request: "prefer to load in tiles that are working and then the most
* recent, so the oldest don't get opened"):
* 1. WORKING, the most recently started turn first;
* 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
* permission or question dialog; `waiting`: a finished turn not seen
* yet), most recent first;
* 3. then every other one (idle, done, error), most recently active first.
* Inside a group the newest stamp wins (tileRankStamp) and a session with no
* stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
* the result never shuffles. The states are the home screens' own
* (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
* order differs: the home screens put the longest-running turn first, the grid
* the most recent.
*
* Pure. Unit-tested in test/tile-grid-ranking.test.ts.
*
* @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
* @returns {string[]} the ids, best first, each once
*/
function rankTileSessions(rows) {
const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
list.sort((a, b) => {
const byGroup = group(a) - group(b);
if (byGroup !== 0) return byGroup;
const atA = tileRankStamp(a);
const atB = tileRankStamp(b);
if (atA !== atB) {
if (!atA) return 1;
if (!atB) return -1;
return atB - atA;
}
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const ids = [];
for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
return ids;
}
/**
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
* (owner decision 8). In order:
* a. the grid this tab last had (`stored`, already sanitized: live, not
* detached, at most the cap), if any of its sessions survive, exactly
* as it was (restoreTileGridCells: its cells and holes, a cell its
* session freed filled from the ranking);
* detached, at most the cap), if any of its sessions survive;
* b. else an open split's two sessions, Pane A focused;
* c. else the open sessions in `ranked` order (rankTileSessions: working,
* then needing input, then the most recent; tab order when no ranking is
* given), detached ones never, up to `limit`, the active session always
* among them and focused (when it ranks past the limit, the first
* `limit - 1` others come with it).
* c. else the open sessions in tab order (buildTilePickerSessions: no
* detached ones), up to `limit`, the active session always among them and focused
* (when it sits past the limit, the first `limit - 1` others come with it).
* Null when there is nothing to open.
*
* @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
* focused: string|null, zoomed: string|null}|null,
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
* focusedId: string|null}|null}
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
*/
function tileGridOpenSet({
stored = null,
split = null,
ranked = null,
sessions,
sessionOrder,
detachedIds,
activeId = null,
limit,
}) {
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
(c) => c.id
);
const restored = stored ? restoreTileGridCells(stored, all) : null;
if (restored) return { source: 'stored', ...restored };
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
if (stored?.ids?.length) {
const focus = stored.zoomed || stored.focused;
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
}
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
const pair = (split || []).filter(usable);
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
if (all.length === 0) return null;
let ids = all.slice(0, max);
if (all.includes(activeId) && !ids.includes(activeId)) {
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
}
return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
}
/**
@@ -2501,9 +2264,8 @@ function sanitizeTileCount(raw) {
* `base` (what the grid would open, or what an open grid shows, in its order)
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
* tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
* the app passes the ranking, rankTileSessions) with the ones not in it yet.
* Fewer sessions than `n` give fewer tiles.
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
*
* @param {string[]} base
* @param {string[]} all
@@ -3012,8 +2774,6 @@ if (typeof window !== 'undefined') {
fitTileCells,
cycleTile,
tileGridOpenSet,
restoreTileGridCells,
rankTileSessions,
sanitizeTileCount,
tileGridSetForCount,
tileCellCols,
+43 -490
View File
@@ -1,13 +1,12 @@
/**
* @fileoverview Entrance animations for the things that appear when work
* @fileoverview Entrance animations for the four things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
* that set all five to a matching look.
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Animations.
* → Appearance → Entrance Animations.
*
* Four constraints shape the design:
*
@@ -35,28 +34,13 @@
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
* before) and switched on in App Settings → Animations → Tile Animations, or
* preset by a theme. A styled tile plays in two beats that combine two
* surfaces. The FRAME enters as it mounts, in its own style
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
* pane). The load queue serves one capture at a time, so
* the screens light up one after another, the focused tile first. The frame
* styles move transform and opacity only (six tiles animate at once; a blur
* belongs to the serialized screen beat), and each has its own way out on the
* closing grid's still copy. A reload restores with `settle` whatever the
* setting.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
* replay, a window and a line, and replays the tile grid in place, so styles
* can be compared without spawning real sessions or agents.
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
* tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
@@ -110,6 +94,7 @@ const LINE_ANIM_STYLES = [
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
@@ -117,79 +102,30 @@ const TERM_ANIM_STYLES = [
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/**
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
* still fits once at its final size (#464). `stagger` is the default gap
* between tiles and `order` the default cascade: `reading` (row by row),
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
* tile). `from`: the tile is measured against a source after layout (its tab,
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
*/
// prettier-ignore
const TILE_ANIM_STYLES = [
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
];
/** Cascade orders for the tile grid; `auto` is each style's own. */
const TILE_ANIM_ORDERS = [
{ key: 'auto', label: 'Style default' },
{ key: 'reading', label: 'Reading order' },
{ key: 'wave', label: 'Diagonal wave' },
{ key: 'ripple', label: 'Ripple from focus' },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
const TILE_EXIT_STAGGER_MS = 35;
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
];
/**
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
* when it is picked, but the tile style is its own setting (App Settings →
* Animations → Tile Animations, off by default), so changing it afterwards
* does not turn the theme into "Custom".
*/
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Animations,
* which persists to the localStorage keys below.
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
const TILE_ANIM_DEFAULT = 'settle';
const TILE_ANIM_ORDER_DEFAULT = 'auto';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
@@ -199,8 +135,6 @@ const ANIM_KEYS = {
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
tile: 'codeman:tileAnim',
tileOrder: 'codeman:tileAnimOrder',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
@@ -230,10 +164,6 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
// were a surface gives them nothing new.
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
@@ -289,18 +219,6 @@ Object.assign(CodemanApp.prototype, {
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
setTileAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
},
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
setTileAnimOrder(key, { persist = true } = {}) {
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
this._syncAnimLab?.();
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
@@ -316,29 +234,22 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this.setTileAnimStyle(theme.tile);
this._syncEntranceAnimSetting?.();
},
/** The current style of each surface, keyed as ANIM_SURFACES. */
_currentAnimStyles() {
return {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
tile: this._tileAnimStyle,
};
},
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
/** The theme matching the four current styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const current = this._currentAnimStyles();
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
return match ? match.key : 'custom';
},
// ── App Settings → Animations ─────────────────────────────────────────────
// ── App Settings picker ───────────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
@@ -346,40 +257,16 @@ Object.assign(CodemanApp.prototype, {
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (sel) {
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
}
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
// tracking, so this closes it as Cancel does (the row says so).
const labBtn = document.getElementById('appSettingsOpenAnimLab');
if (labBtn && !labBtn.dataset.bound) {
labBtn.dataset.bound = '1';
labBtn.addEventListener('click', () => {
this.closeAppSettings?.();
this.openAnimLab();
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
// Tile Animations: its own row, off (`settle`) by default. A theme picked
// above presets it; picked here, it applies to tiles alone.
const tileSel = document.getElementById('appSettingsTileAnim');
if (tileSel) {
tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
if (!tileSel.dataset.bound) {
tileSel.dataset.bound = '1';
tileSel.addEventListener('change', () => {
this.setTileAnimStyle(tileSel.value);
this._syncEntranceAnimSetting();
});
}
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
@@ -416,10 +303,6 @@ Object.assign(CodemanApp.prototype, {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
_tileAnimDuration() {
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
@@ -618,317 +501,6 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Tile grid ─────────────────────────────────────────────────────────────
/** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
tileEntranceStyle() {
return this._tileAnimStyle || TILE_ANIM_DEFAULT;
},
/**
* Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
* until the next frame, when its cell is final and _runTileEntrances can
* order it and measure it against its source. `setBackstop(ms)` arms the
* mount's own timer that ends the entrance should animationend never come
* (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
* so a frame that never comes cannot strand a tile invisible.
*/
_stageTileEntrance(el, sessionId, setBackstop) {
el.classList.add('tile--enter-themed', 'tile--enter-hold');
(this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
setBackstop(4000);
if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
},
/**
* One frame after the tiles mounted, every cell is final (openTileGrid packs,
* then a stored grid moves its tiles back). Each held tile gets its delay
* from the cascade order and, for `fly`/`deal`, the offset that starts it on
* its tab or the Tiles button (FLIP: transform only, so the fit it already
* did at its real size stands). `beam` draws its lines instead.
*/
_runTileEntrances() {
this._tileEnterRaf = 0;
const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
this._tileEnterQueue = [];
if (queue.length === 0) return;
const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
const speed = this._animSpeed || 1;
const stagger = def.stagger / speed;
const duration = this._tileAnimDuration();
const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
const ranks = this._tileEnterRanks(
queue.map((q) => q.sessionId),
order
);
const beams = [];
queue.forEach((item, k) => {
const { el, sessionId } = item;
const delay = ranks[k] * stagger;
el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
if (def.from) {
const to = el.getBoundingClientRect();
const from = this._tileSourceRect(def.from, sessionId);
if (from && to.width > 0 && to.height > 0) {
if (def.key === 'beam') beams.push({ from, to, delay });
else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
}
}
el.classList.remove('tile--enter-hold');
// When the frame lands, for a screen whose content arrives earlier.
el._tileEnterEndsAt = performance.now() + delay + hold + duration;
item.setBackstop(delay + hold + duration + 900);
});
if (beams.length > 0) this._drawTileBeams(beams);
},
/**
* Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
* row by row, `wave` by diagonal (row + column), `ripple` by distance from
* the focused tile. Equal keys share a step, so a diagonal lands together.
*/
_tileEnterRanks(ids, order) {
const grid = this._tileGrid;
const cols = Math.max(1, grid?.cols || 1);
const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
const pos = ids.map((id, k) => {
const c = cellOf(id);
return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
});
let keys;
if (order === 'wave') {
keys = pos.map((p) => p.row + p.col);
} else if (order === 'ripple') {
const f = cellOf(grid?.focusedId);
const fr = f < 0 ? 0 : Math.floor(f / cols);
const fc = f < 0 ? 0 : f % cols;
keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
} else {
keys = pos.map((p) => p.cell);
}
const steps = [...new Set(keys)].sort((a, b) => a - b);
return keys.map((v) => steps.indexOf(v));
},
/** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
_tileSourceRect(kind, sessionId) {
const visible = (node) => {
const r = node?.getBoundingClientRect?.();
if (!r || !(r.width > 0 && r.height > 0)) return null;
return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
};
if (kind === 'tab') {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
const r = visible(tab);
if (r) return r;
}
return visible(document.querySelector('.btn-tile-grid'));
},
/**
* The transform that puts a tile laid out at `to` onto `from`, as custom
* properties `<prefix>-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
* size for `fly` (it grows out of it), a small card turned a little for
* `deal`.
*/
_setTileFlight(el, from, to, kind, rank, prefix) {
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let sx;
let sy;
let rot = 0;
if (kind === 'deal') {
sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
rot = [-14, 10, -7, 13, -11, 8][rank % 6];
} else {
sx = clamp(from.width / to.width, 0.02, 1);
sy = clamp(from.height / to.height, 0.02, 1);
}
el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
},
/** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
_flashTileSourceTab(sessionId, delay) {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
if (!tab) return;
tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
tab.classList.remove('tab-launch');
void tab.offsetWidth;
tab.classList.add('tab-launch');
const onEnd = (e) => {
if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
};
const done = () => {
clearTimeout(timer);
tab.removeEventListener('animationend', onEnd);
tab.classList.remove('tab-launch');
tab.style.removeProperty('--tab-launch-delay');
};
const timer = setTimeout(done, delay + 900);
tab.addEventListener('animationend', onEnd);
},
/**
* `beam`: a line draws from each tile's tab (or the Tiles button) down into
* the middle of its tile, in the connection-line look, with a packet riding it
* when the line style is `packet`; the tile materializes as it lands. Its
* own overlay: the agent lines' one is rebuilt from scratch on every redraw.
* The overlay goes once every line has faded.
*/
_drawTileBeams(beams) {
const ns = 'http://www.w3.org/2000/svg';
let svg = document.getElementById('tileBeamLines');
if (!svg) {
svg = document.createElementNS(ns, 'svg');
svg.id = 'tileBeamLines';
svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
svg.setAttribute('aria-hidden', 'true');
document.body.appendChild(svg);
}
const packet = this._lineAnimStyle === 'packet';
let last = 0;
for (const { from, to, delay } of beams) {
const x1 = from.left + from.width / 2;
const y1 = from.bottom;
// Into the tile's middle: its top edge sits right under the tab strip,
// so a beam aimed there ran sideways along the strip instead of down.
const x2 = to.left + to.width / 2;
const y2 = to.top + to.height / 2;
const midY = (y1 + y2) / 2;
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line tile-beam-line');
svg.appendChild(path);
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
if (packet) {
const dot = path.cloneNode(false);
dot.setAttribute('class', 'connection-line-packet');
svg.appendChild(dot);
}
last = Math.max(last, delay);
}
clearTimeout(this._tileBeamTimer);
this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
},
/**
* A tile's screen lights up when its first capture lands (tile-grid.js load
* queue), in the terminal pane's style: the same keyframes as the main pane,
* on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
* tile at a time, since the queue serves one capture at a time), so the
* xterm inside keeps its size and its fit.
*/
playTileScreenEntrance(body) {
if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
body._codemanScreenDone?.();
// Content that lands while the frame is still flying in waits (held at
// its first keyframe, so hidden) until the frame has nearly landed: two
// beats, frame then screen, rather than both at once.
const frame = body.closest?.('.tile');
const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
body.style.setProperty('--tile-screen-delay', `${wait}ms`);
body.classList.remove('term-enter');
void body.offsetWidth;
body.classList.add('term-enter');
let timer = null;
const done = (e) => {
if (e && (e.target !== body || e.pseudoElement)) return;
clearTimeout(timer);
body.removeEventListener('animationend', done);
body.removeEventListener('animationcancel', done);
body.classList.remove('term-enter');
body.style.removeProperty('--tile-screen-delay');
if (body._codemanScreenDone === done) body._codemanScreenDone = null;
};
body._codemanScreenDone = done;
body.addEventListener('animationend', done);
body.addEventListener('animationcancel', done);
// Backstop, as the main pane's: a backgrounded tab never fires animationend.
timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
},
/**
* The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
* frame style's own way: `fly` back into each tab, `deal` gathered into the
* Tiles button, `crt` switched off to a dot, and so on. Copies leave in
* reading order, so the last one ends last (the layer goes on its
* animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
* covers tiles that stay. Returns how long the slowest copy takes, for the
* layer's fallback timer, or 0 for the default fade.
*/
_stageTileExit(copies, { now = false } = {}) {
const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
const ms = TILE_EXIT_MS[style];
if (now || !ms || copies.length === 0) return 0;
const speed = this._animSpeed || 1;
const def = TILE_ANIM_STYLES.find((s) => s.key === style);
copies.forEach(({ ghost, el, sessionId }, k) => {
ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
if (style !== 'fly' && style !== 'deal') return;
const at = el.getBoundingClientRect();
const target = this._tileSourceRect(def.from, sessionId);
if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
});
return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
},
/**
* Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
* light up again in queue order): no remount, reconnect or resize. With the
* grid closed it opens it, the real path.
*/
_demoTiles() {
const grid = this._tileGrid;
if (!grid?.open) {
if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
return;
}
if (!this._tileMotionAllowed?.()) return;
const ids = grid.ids.slice();
ids.forEach((id, k) => {
const entry = grid.tiles.get(id);
if (entry) this._replayTileEntrance?.(entry.el, id, k);
});
// The screens, as the load queue would land them: focused first, then reading order.
const speed = this._animSpeed || 1;
const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
clearTimeout(this._tileDemoTimer);
const timers = order.map((id, k) =>
setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
);
this._tileDemoTimers?.forEach(clearTimeout);
this._tileDemoTimers = timers;
},
/** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
_demoTilesRoundTrip() {
if (!this._tileGrid?.open) {
this._demoTiles();
return;
}
this.toggleTileGrid?.();
clearTimeout(this._tileDemoTimer);
this._tileDemoTimer = setTimeout(
() => {
if (!this._tileGrid?.open) this.toggleTileGrid?.();
},
1400 / (this._animSpeed || 1)
);
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
@@ -966,12 +538,6 @@ Object.assign(CodemanApp.prototype, {
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
<label class="anim-lab-select">Tile order
<select data-select="tileOrder">
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
</select>
</label>
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
@@ -986,11 +552,6 @@ Object.assign(CodemanApp.prototype, {
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<div class="anim-lab-demo">
<span>Tiles</span>
<button type="button" data-demo="tiles">Replay</button>
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
@@ -1009,17 +570,11 @@ Object.assign(CodemanApp.prototype, {
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else if (attr === 'tile') this.setTileAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this._syncEntranceAnimSetting?.();
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
});
});
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
this.setTileAnimOrder(e.target.value);
this.demoEntrance('tiles');
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
@@ -1046,16 +601,19 @@ Object.assign(CodemanApp.prototype, {
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = this._currentAnimStyles();
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
});
const order = panel.querySelector('select[data-select="tileOrder"]');
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
@@ -1089,15 +647,10 @@ Object.assign(CodemanApp.prototype, {
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
/** @param {'tabs'|'term'|'window'|'all'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
if (what === 'tiles') return this._demoTiles();
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
// With the grid open, the "pane" is every tile's screen.
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
+6 -112
View File
@@ -4448,7 +4448,6 @@ 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";
@@ -4458,8 +4457,6 @@ 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() {
@@ -4536,7 +4533,7 @@ var GestureBridge = class {
await this.gc.start();
this.running = true;
this.button.classList.add("on");
this.status.textContent = "on: pinch a tab, tile, window, or button";
this.status.textContent = "on \u2014 pinch a tab, window, or button";
} catch (err) {
const msg = describeError(err);
this.status.textContent = `failed: ${msg}`;
@@ -4591,30 +4588,6 @@ 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) {
@@ -4626,7 +4599,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, target: null });
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
this.positionGhost(ghost, x2, y2);
return;
}
@@ -4640,30 +4613,13 @@ 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;
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) {
if (pulled !== grab.armed) {
grab.armed = pulled;
grab.ghost.classList.toggle("cg-armed", pulled);
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
}
return;
}
@@ -4685,40 +4641,16 @@ 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: pinch a tab, tile, window, or button";
this.status.textContent = "on \u2014 pinch a tab, 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");
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);
if (grab.armed) this.floatPanel(grab.id, x2, y2);
else this.flash("cancelled");
return;
}
@@ -4843,41 +4775,11 @@ 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");
@@ -4889,7 +4791,6 @@ 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;
@@ -4962,13 +4863,6 @@ 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;
+1 -2
View File
@@ -428,8 +428,7 @@ Object.assign(CodemanApp.prototype, {
line1.appendChild(badge);
} else {
// The agent's logo: PR #532's slot, the mode id as data (an id with no
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
// logo rule gets the slot's plain dot).
const logo = document.createElement('span');
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
logo.setAttribute('aria-hidden', 'true');
+3 -58
View File
@@ -25,7 +25,6 @@
'.response-viewer-content',
'.file-preview-content',
'.session-tab-name',
'.tab-name',
'.session-name',
'.case-name',
'.notif-item-message',
@@ -62,8 +61,6 @@
'Collapse session sidebar': '收起会话侧边栏',
'Expand session sidebar': '展开会话侧边栏',
'Filter sessions': '筛选会话',
'Search sessions': '搜索会话',
'No sessions match': '没有匹配的会话',
'Admin Panel': '管理面板',
'Open admin panel': '打开管理面板',
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
@@ -139,8 +136,6 @@
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': '将该会话加入平铺网格',
@@ -168,11 +163,6 @@
'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: '附加',
@@ -221,9 +211,6 @@
'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 编程工具。',
@@ -353,36 +340,6 @@
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
English: 'English',
Appearance: '外观',
// App Settings > Animations (#571). 平铺 is the grid, 窗格 one tile in it.
Animations: '动画',
'How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.':
'标签页、终端面板、智能体窗口和平铺窗格如何出现。默认全部关闭,选择后立即生效。',
Entrances: '入场',
'Entrance Theme': '入场主题',
'One look for how new tabs, terminal panes, agent windows and their lines arrive.':
'为新标签页、终端面板、智能体窗口及其连线的出现方式选择统一的风格。',
'Off (default)': '关闭(默认)',
'Terminal (CRT)': '终端(CRT)',
'Beam down': '光束降临',
'Launch (tiles fly from tabs)': '发射(窗格从标签页飞出)',
'Soft focus (blur)': '柔焦(模糊)',
Quiet: '安静',
Playful: '活泼',
'Custom (set in the lab)': '自定义(在实验室中设置)',
'Tile Animations': '平铺动画',
'How tiles arrive when the grid opens and leave when it closes. A theme above presets it.':
'平铺打开时窗格如何出现、关闭时如何离开。上方的主题会预设此项。',
'Fly from tab': '从标签页飞出',
Deal: '发牌',
Cascade: '级联',
Pop: '弹出',
Soft: '柔和',
'None (tiles just appear)': '无(窗格直接出现)',
Lab: '实验室',
'Animation Lab': '动画实验室',
'Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.':
'关闭设置,并按界面并排打开所有样式,可重放和调速。等同于在网址后添加 ?animlab=1。',
'Open lab': '打开实验室',
Skin: '皮肤',
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
'Daylight Blue': '日光蓝',
@@ -412,12 +369,6 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
'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.
@@ -641,11 +592,6 @@
'Audio Alerts': '声音提醒',
'Push Notifications': '推送通知',
'Notification Levels': '通知级别',
'Toast display time': '弹出提示显示时长',
'How long the corner pop-ups stay on screen.': '角落弹出提示在屏幕上停留的时长。',
'Browser notification display time': '浏览器通知显示时长',
'How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.':
'桌面通知在 Codeman 关闭它之前保持显示的时长。系统可能会更早关闭它。',
Critical: '严重',
'Per-Event Settings': '按事件设置',
'Permission prompts': '权限提示',
@@ -772,8 +718,6 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
@@ -1293,8 +1237,9 @@
// 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])?))?\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]}`,
/^(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']}`,
],
];
for (const [pattern, replacement] of patterns) {
+1 -3
View File
@@ -150,7 +150,6 @@ Object.assign(CodemanApp.prototype, {
const total = files.length;
let done = 0;
let failed = 0;
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');
@@ -174,7 +173,6 @@ Object.assign(CodemanApp.prototype, {
results[i] = await this._uploadPasteImage(sessionId, normalized);
} catch (err) {
failed++;
if (!failReason && err && err.message) failReason = err.message;
console.warn('Image upload failed:', err);
results[i] = null;
} finally {
@@ -198,7 +196,7 @@ 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 (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
if (failed > 0) parts.push(`${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);
+20 -103
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -394,19 +394,6 @@
<!-- Main Terminal Area -->
<main class="main">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
<!-- Session-name search (app.js setTabRailSearch): a view filter over the
rows below, in memory only. Shown with the rail, never elsewhere. -->
<div class="session-sidebar-filter tab-rail-search">
<input type="search" id="tabRailSearch" class="session-sidebar-filter-input"
placeholder="Search sessions" aria-label="Search sessions"
autocomplete="off" spellcheck="false"
oninput="app.setTabRailSearch(this.value)"
onkeydown="app.handleTabRailSearchKeydown(event)">
<button type="button" id="tabRailSearchClear" class="tab-rail-search-clear"
aria-label="Clear search" title="Clear search"
onclick="app.clearTabRailSearch()" hidden>&times;</button>
</div>
<div id="tabRailSearchEmpty" class="tab-rail-search-empty" role="status" hidden>No sessions match</div>
<div
id="tabRailResizeHandle"
class="tab-rail-resize-handle"
@@ -860,7 +847,6 @@
<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>
@@ -1649,7 +1635,6 @@
Save; row-reverse keeps Save to the left of it on phones. -->
<div class="set-head-actions">
<button class="modal-close" onclick="app.closeAppSettings()" aria-label="Close app settings">&times;</button>
<button class="set-head-save set-head-apply" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
<button class="set-head-save" onclick="app.saveAppSettings()">Save</button>
</div>
</div>
@@ -1678,10 +1663,6 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<span>Appearance</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-animations">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<span>Animations</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-models">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
<span>Models</span>
@@ -2072,7 +2053,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<h2>Appearance</h2>
</div>
<p class="set-section-blurb">Theme, and what this install calls itself.</p>
<p class="set-section-blurb">Theme, motion, and what this install calls itself.</p>
<div class="set-group">
<div class="set-group-head"><h4>Theme</h4><span class="set-scope">device</span></div>
@@ -2096,6 +2077,21 @@
</optgroup>
</select>
</div>
<div class="set-row has-field" data-search="entrance animations motion tabs windows">
<div class="set-row-text">
<span class="set-row-label">Entrance Animations</span>
<span class="set-row-desc">How new tabs, panes and agent windows arrive. Add ?animlab=1 to the URL for per-surface control.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="softfocus">Soft focus (blur)</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
</div>
</div>
@@ -2220,13 +2216,6 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
<div class="set-row-text">
<span class="set-row-label">CLI Logos on Tabs</span>
<span class="set-row-desc">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.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
</div>
<div class="set-row" data-search="pop out detach tab window">
<div class="set-row-text">
<span class="set-row-label">Pop-out Button on Tabs</span>
@@ -2234,13 +2223,6 @@
</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>
@@ -2266,70 +2248,6 @@
</div>
</section>
<!-- ══ Animations ═══════════════════════════════════════════════
Every entrance animation in one place (entrance-animations.js).
All per-device localStorage keys, applied as they are picked,
never part of PUT /api/settings. -->
<section class="set-section" id="settings-animations" data-label="Animations">
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<h2>Animations</h2>
</div>
<p class="set-section-blurb">How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.</p>
<div class="set-group">
<div class="set-group-head"><h4>Entrances</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="entrance animations theme motion tabs windows panes lines">
<div class="set-row-text">
<span class="set-row-label">Entrance Theme</span>
<span class="set-row-desc">One look for how new tabs, terminal panes, agent windows and their lines arrive.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="launch">Launch (tiles fly from tabs)</option>
<option value="softfocus">Soft focus (blur)</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
<div class="set-row has-field" data-search="tile animations tiles grid motion entrance fly deal crt beam">
<div class="set-row-text">
<span class="set-row-label">Tile Animations</span>
<span class="set-row-desc">How tiles arrive when the grid opens and leave when it closes. A theme above presets it.</span>
</div>
<select id="appSettingsTileAnim" class="set-select">
<option value="settle">Off (default)</option>
<option value="fly">Fly from tab</option>
<option value="deal">Deal</option>
<option value="crt">CRT</option>
<option value="beam">Beam down</option>
<option value="cascade">Cascade</option>
<option value="pop">Pop</option>
<option value="soft">Soft</option>
<option value="off">None (tiles just appear)</option>
</select>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Lab</h4></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="animation lab compare replay styles per surface animlab">
<div class="set-row-text">
<span class="set-row-label">Animation Lab</span>
<span class="set-row-desc">Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.</span>
</div>
<button type="button" id="appSettingsOpenAnimLab" class="btn-toolbar btn-sm">Open lab</button>
</div>
</div>
</div>
</section>
<!-- ══ Models ═══════════════════════════════════════════════════ -->
<section class="set-section" id="settings-models" data-label="Models">
<div class="set-section-head">
@@ -2726,17 +2644,17 @@
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity copilot github">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs, and GitHub Copilot CLI if it is installed, by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers (and GitHub Copilot CLI's) into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>, <code>COPILOT_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
@@ -3139,7 +3057,6 @@
</div>
<div class="form-actions set-foot">
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
<button class="btn-toolbar" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
<button class="btn-toolbar btn-primary" onclick="app.saveAppSettings()">Save</button>
</div>
</div>
+1 -1
View File
@@ -3552,7 +3552,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
}
/* Save + Close are the two ways out of the sheet (save-and-close vs
discard-and-close; Apply saves but keeps the sheet open), hit in the same corner with the same thumb, so here —
discard-and-close), hit in the same corner with the same thumb, so here —
and only here, since Save is header-only below 860px — they share a
recessed tray and matching pill geometry instead of reading as a fat
accent pill parked beside a stray × glyph. Tray colors come from skin
+1 -1
View File
@@ -483,7 +483,7 @@ class NotificationManager {
notif.read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
}
}
// Switch to session if available
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
+8 -105
View File
@@ -487,7 +487,6 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
@@ -510,11 +509,6 @@ 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(
@@ -1232,15 +1226,15 @@ Object.assign(CodemanApp.prototype, {
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html, hint = '') => {
if (out) { out.style.display = 'block'; out.innerHTML = html; out.dataset.hint = hint; }
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Apply or Save settings to turn MCP sync on first, then preview or sync.', 'save-first');
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file (and GitHub Copilot CLI\'s, when it is installed)? Env values and headers on those servers are copied too.')) return;
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
show('Working…');
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
let body = null;
@@ -2518,31 +2512,7 @@ Object.assign(CodemanApp.prototype, {
claudeEl.className = 'voice-provider-status' + (status?.available ? ' active' : '');
},
/**
* Apply button: the same save as Save, but the modal stays open and the MCP sync group (its
* Preview and Sync need the saved flag) and the CLI management writes are refreshed in place, so
* turning either on needs no close-and-reopen. It is a wrapper rather than an option on
* saveAppSettings() so that function's signature (which tests locate by text) stays as it was.
*
* `_keepSettingsOpenOnce` is the one-shot intent and `_applyInFlight` the double-click guard:
* saveAppSettings() consumes the intent before its first await, so a Save clicked while an Apply
* is still in flight is an ordinary Save and closes the modal.
*/
async applyAppSettings() {
if (this._applyInFlight) return;
this._applyInFlight = true;
this._keepSettingsOpenOnce = true;
try {
await this.saveAppSettings();
} finally {
this._applyInFlight = false;
this._keepSettingsOpenOnce = false;
}
},
async saveAppSettings() {
const keepOpen = this._keepSettingsOpenOnce === true;
this._keepSettingsOpenOnce = false;
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
@@ -2613,7 +2583,6 @@ Object.assign(CodemanApp.prototype, {
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
@@ -2621,7 +2590,6 @@ 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
@@ -2873,9 +2841,6 @@ 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
@@ -2892,7 +2857,6 @@ Object.assign(CodemanApp.prototype, {
...serverSettings
} = settings;
let webhookError = '';
let serverSaved = false;
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2910,14 +2874,10 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
if (!keepOpen) this.closeAppSettings();
this.closeAppSettings();
return;
}
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// Save model configuration separately
await this.saveModelConfigFromSettings();
@@ -2929,7 +2889,7 @@ Object.assign(CodemanApp.prototype, {
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else {
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
this.showToast('Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
@@ -2941,13 +2901,9 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
// Only when the settings PUT landed: after a 400 or a dropped connection the server still has the
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else if (!keepOpen) {
} else {
this.closeAppSettings();
}
@@ -2968,25 +2924,6 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* After Apply: bring the groups whose contents depend on a SAVED value up to date without
* reopening the modal. openAppSettings does the same on open; this is the part of it that
* a save can change, without touching what the user is editing or the scroll position.
*/
_refreshSettingsAfterApply(settings) {
// The MCP routes read the saved flag, so switching it on is only usable from now.
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
const out = this.$('mcpSyncResult');
if (this._mcpSyncSavedOn && out && out.dataset?.hint === 'save-first') {
out.style.display = 'none';
out.innerHTML = '';
}
this.applyMcpSyncVisibility();
this.applyCustomModelEndpointsVisibility();
this.applyCliManagementVisibility();
this._applyDoctorAdminGate();
},
// Load model configuration from server for the settings modal
async loadModelConfigForSettings() {
try {
@@ -3604,7 +3541,6 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
showTabCliLogos: true,
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
@@ -3741,16 +3677,6 @@ Object.assign(CodemanApp.prototype, {
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
},
/**
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
* device). Anything but an explicit false reads as on, the same test the
* pre-paint script in index.html applies, so a reload and a Save never
* disagree about an odd stored value.
*/
tabCliLogosEnabled(settings) {
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
},
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
resolveTabStateOrder(settings) {
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
@@ -3911,8 +3837,6 @@ 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
@@ -4008,10 +3932,6 @@ Object.assign(CodemanApp.prototype, {
})
: 'horizontal';
// The search box lives in the rail: a search left applied after the list
// moves out would hide tabs with no box to clear it from.
if (orientation !== 'vertical' && this._tabRailSearch) this._resetTabRailSearch?.();
const root = document.documentElement;
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
@@ -4044,14 +3964,6 @@ Object.assign(CodemanApp.prototype, {
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
const stateOrder = this.resolveTabStateOrder(settings);
root.dataset.tabStateOrder = stateOrder;
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
// of `changed` below: the logo spans are always in the markup. It still
// resizes every agent tab, which the tail of this function settles.
const previousLogos = root.dataset.tabLogos;
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
root.dataset.tabLogos = logos;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
@@ -4101,14 +4013,6 @@ Object.assign(CodemanApp.prototype, {
if (!wrapRendered) this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
} else if (previousLogos !== logos) {
// A logo flip narrows or widens every agent tab with no render behind
// it, so re-take what a render would have: the strip's one-row wrap
// decision and the lines anchored to tab rects (lineage, subagent
// connectors). A header that gains or loses a row resizes the terminal
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
this.updateTabOverflowMode?.();
this._updateConnectionLinesImmediate?.();
}
// Only detailed rows carry stamps that go stale with no event behind them.
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
@@ -4357,7 +4261,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
@@ -4369,7 +4273,6 @@ 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
+53 -689
View File
@@ -1270,26 +1270,6 @@ html[data-tab-anim="blur"] .session-tab.tab-enter {
}
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
.anim-lab-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -2px 0 10px;
padding: 0 2px;
color: var(--text-dim);
font-size: 0.66rem;
}
.anim-lab-select select {
background: var(--bg-dark, #111);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.66rem;
padding: 2px 4px;
}
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
.anim-lab-demo {
@@ -1476,20 +1456,11 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
frame. `will-change` is deliberately not set, the base rule needs its
`will-change: contents` for terminal compositing. */
/* A tile's screen (.tile-body, tile-grid.js) plays the same style when its
first capture lands: the same rules, so a tile combines its frame's own
entrance with the pane style. Its xterm's opacity fade stands aside for it. */
.terminal-container.term-enter,
.tile-body.term-enter {
.terminal-container.term-enter {
animation-fill-mode: both;
}
.tile-body.term-enter .xterm {
transition: none;
}
.terminal-container.term-enter::before,
.tile-body.term-enter::before {
.terminal-container.term-enter::before {
content: "";
position: absolute;
inset: 0;
@@ -1499,29 +1470,14 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
animation-fill-mode: both;
}
/* A screen whose content lands while its frame is still entering waits for
it (--tile-screen-delay, entrance-animations.js): the body holds its first
keyframe meanwhile (hidden), its wash does not (a held wash is a bright
static block). */
.tile-body.term-enter {
animation-delay: var(--tile-screen-delay, 0ms);
}
.tile-body.term-enter::before {
animation-delay: var(--tile-screen-delay, 0ms);
animation-fill-mode: forwards;
}
/* CRT, power-on: a hot line that expands to full height. */
html[data-term-anim="crt"] .terminal-container.term-enter,
html[data-term-anim="crt"] .tile-body.term-enter {
html[data-term-anim="crt"] .terminal-container.term-enter {
animation-name: term-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-term-anim="crt"] .terminal-container.term-enter::before,
html[data-term-anim="crt"] .tile-body.term-enter::before {
html[data-term-anim="crt"] .terminal-container.term-enter::before {
animation-name: term-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1542,16 +1498,55 @@ html[data-term-anim="crt"] .tile-body.term-enter::before {
100% { opacity: 0; background: transparent; }
}
/* Boot, flickers on under a green scan sweep. */
html[data-term-anim="boot"] .terminal-container.term-enter {
animation-name: term-enter-boot;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-term-anim="boot"] .terminal-container.term-enter::before {
background: linear-gradient(
180deg,
transparent 0%,
rgba(0, 255, 102, 0.05) 40%,
rgba(200, 255, 220, 0.28) 50%,
rgba(0, 255, 102, 0.05) 60%,
transparent 100%
);
background-size: 100% 300%;
animation-name: term-enter-boot-scan;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes term-enter-boot {
0% { opacity: 0; transform: scale(0.995); }
8% { opacity: 0.85; }
15% { opacity: 0.1; }
24% { opacity: 1; }
33% { opacity: 0.35; }
45% { opacity: 1; transform: none; }
58% { opacity: 0.7; }
70% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-boot-scan {
0% { opacity: 0; background-position: 0 -150%; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: 0 150%; }
}
/* Wipe, reveals top-to-bottom behind a bright edge. */
html[data-term-anim="wipe"] .terminal-container.term-enter,
html[data-term-anim="wipe"] .tile-body.term-enter {
html[data-term-anim="wipe"] .terminal-container.term-enter {
animation-name: term-enter-wipe;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
html[data-term-anim="wipe"] .terminal-container.term-enter::before,
html[data-term-anim="wipe"] .tile-body.term-enter::before {
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
animation-name: term-enter-wipe-edge;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
@@ -1570,8 +1565,7 @@ html[data-term-anim="wipe"] .tile-body.term-enter::before {
}
/* Slide up, rises into place from below. */
html[data-term-anim="slide"] .terminal-container.term-enter,
html[data-term-anim="slide"] .tile-body.term-enter {
html[data-term-anim="slide"] .terminal-container.term-enter {
animation-name: term-enter-slide;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
@@ -1583,8 +1577,7 @@ html[data-term-anim="slide"] .tile-body.term-enter {
}
/* Fade, quiet, with a touch of scale. */
html[data-term-anim="fade"] .terminal-container.term-enter,
html[data-term-anim="fade"] .tile-body.term-enter {
html[data-term-anim="fade"] .terminal-container.term-enter {
animation-name: term-enter-fade;
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1622,8 +1615,7 @@ html[data-term-anim="fade"] .tile-body.term-enter {
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
before, during and after a run), and the property allowlist for every one of
these keyframes is pinned by test/entrance-animations.test.ts. */
html[data-term-anim="blur"] .terminal-container.term-enter,
html[data-term-anim="blur"] .tile-body.term-enter {
html[data-term-anim="blur"] .terminal-container.term-enter {
animation-name: term-enter-blur;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
@@ -1746,20 +1738,10 @@ html[data-line-anim="blur"] .connection-line.line-enter {
.ultracode-window.win-enter::before,
.terminal-container.term-enter,
.terminal-container.term-enter::before,
.tile-body.term-enter,
.tile-body.term-enter::before,
.tile.tile--entering,
.tile.tile--entering::before,
.connection-line.line-enter,
.connection-line-packet,
.connection-line.tile-beam-line,
.session-tab.tab-launch::after {
.connection-line-packet {
animation: none !important;
}
.tile-beam-lines {
display: none;
}
}
.session-tab .tab-status {
@@ -2990,14 +2972,6 @@ 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;
@@ -3036,19 +3010,6 @@ body.solo-mode .btn-lifecycle-log {
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
colour lives here any more. Only the shell keeps a pill. */
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
applyTabOrientation() and the pre-paint script in index.html stamp the
attribute). The logo leaves the session tabs (header strip, vertical rail,
sidebar, grouped rail, phone chips) and the desktop home rail; the status
dot and the shell's SH pill stay. Every row it sits in spaces its children
with flex `gap`, which skips a display:none item, so no empty slot is left.
Tile and split headers (.tile-harness, .split-harness) and the Run menus
keep their logos: only these two classes are named here. */
html[data-tab-logos='off'] .session-tab .tab-harness,
html[data-tab-logos='off'] .home-sessions-harness {
display: none;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -17013,12 +16974,6 @@ html[data-tab-orientation='vertical'] .home-sessions {
transform 0.1s ease;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save.set-head-apply {
background: transparent;
color: var(--text);
box-shadow: inset 0 0 0 1px var(--border);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:hover {
filter: brightness(1.08);
}
@@ -18822,83 +18777,11 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-right {
Scoped to the sidebar layout on purpose: applySidebarFilter() already strips
the class whenever the filter box is off screen, and this prefix is the
second lock — a leaked class must never be able to hide tabs from the header
strip, which has no filter control to clear it with. The shared filter
(_applyTabListFilter) also marks a case box (tabArrangement 'case') the
filter emptied, which hides with its label and count rather than staying
on screen reading 0. */
html[data-session-list="sidebar"] .session-tab.tab-filtered-out,
html[data-session-list="sidebar"] .tab-cluster.tab-filtered-out {
strip, which has no filter control to clear it with. */
html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
display: none !important;
}
/* The vertical rail's session search (app.js _applyTabListFilter) uses the same
class, and also hides a group or case box the search emptied. Rail-scoped for
the same reason as the sidebar rule above: the header strip has no box. */
html[data-tab-orientation='vertical'] .tab-rail .session-tab.tab-filtered-out,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group.tab-filtered-out,
html[data-tab-orientation='vertical'] .tab-rail .tab-cluster.tab-filtered-out {
display: none !important;
}
/* The box reuses .session-sidebar-filter / -input; this only adds the clear
button over its right edge and the "No sessions match" line. */
.tab-rail-search {
position: relative;
/* Clear of the 16px resize handle on the rail's right edge. */
padding-right: calc(0.25rem + 16px);
}
.tab-rail-search .session-sidebar-filter-input {
padding-right: 1.6rem;
}
/* The native WebKit clear glyph would sit under ours. */
.tab-rail-search .session-sidebar-filter-input::-webkit-search-cancel-button {
appearance: none;
}
.tab-rail-search-clear {
position: absolute;
top: 50%;
right: calc(0.45rem + 16px);
width: 1.2rem;
height: 1.2rem;
padding: 0;
transform: translateY(-50%);
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-dim);
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
}
.tab-rail-search-clear:hover,
.tab-rail-search-clear:focus-visible {
background: var(--control-bg-hover);
color: var(--text);
}
.tab-rail-search-clear[hidden],
.tab-rail-search-empty[hidden] {
display: none;
}
.tab-rail-search-empty {
flex-shrink: 0;
padding: 0.5rem 0.75rem;
color: var(--text-dim);
font-size: 0.75rem;
text-align: center;
}
/* Every group is drawn open while searching and its header will not toggle,
so the chevron dims to say so. */
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-filtering .tab-layout-group-chevron {
opacity: 0.35;
}
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
/* The detailed variant of the SAME sidebar: identical column, identical
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
@@ -20179,314 +20062,6 @@ body.tile-grid-resizing--row * {
}
}
/* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ───
Picked in App Settings → Animations (Tile Animations, or preset by the
Entrance Theme) or per surface in the lab (?animlab=1); `settle` is the rule above, the default,
and what a reload restores with. Any other style is held one frame
(.tile--enter-hold: invisible, not animating) while the entrance module
orders the cascade and measures the tile's source on the final layout, then
arrives with .tile--enter-themed and an inline --tile-enter-delay (and, for
`fly` and `deal`, the --tile-from-* offset onto the tab or the Tiles button).
FRAME keyframes animate transform and opacity only: FitAddon reads the
untransformed layout box (#464), and six tiles run at once, so no filter
(a blur belongs to the screen beat, one tile at a time). Colour goes on a
::before wash. Each style also has its own way out on the closing grid's
still copy (tile-leave-*, below). */
html[data-tile-anim="settle"] .tile.tile--entering {
animation-name: tile-enter;
}
.tile.tile--entering.tile--enter-themed {
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity;
}
.tile.tile--entering.tile--enter-themed::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
}
.tile.tile--enter-hold {
opacity: 0;
}
.tile.tile--enter-hold,
.tile.tile--enter-hold::before {
animation: none !important;
}
/* Fly from tab: grows out of its session tab (FLIP onto the tab's own box),
overshoots a hair and lands. */
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-fly;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.2, 0.9, 0.25, 1);
}
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed::before {
animation-name: tile-wash-launch;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-fly {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -48px))
scale(var(--tile-from-sx, 0.3), var(--tile-from-sy, 0.1));
}
16% {
opacity: 1;
}
74% {
transform: translate(0, 0) scale(1.01);
}
100% {
opacity: 1;
transform: none;
}
}
@keyframes tile-wash-launch {
0% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent);
box-shadow: inset 0 0 0 2px var(--accent, #4a9eff);
}
55% {
opacity: 0.55;
background: color-mix(in srgb, var(--accent, #4a9eff) 12%, transparent);
}
100% {
opacity: 0;
background: transparent;
box-shadow: inset 0 0 0 1px transparent;
}
}
/* Deal: dealt out of the Tiles button like cards, each turned a little, then
squared up in its cell. */
html[data-tile-anim="deal"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-deal;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 0.85, 0.3, 1);
}
@keyframes tile-enter-deal {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -240px)) scale(var(--tile-from-sx, 0.1))
rotate(var(--tile-from-rot, -10deg));
}
10% {
opacity: 1;
}
70% {
transform: translate(0, 0) scale(1.02) rotate(calc(var(--tile-from-rot, -10deg) * -0.1));
}
88% {
transform: scale(0.996) rotate(0deg);
}
100% {
opacity: 1;
transform: none;
}
}
/* CRT: powers on as a hot line, then unfolds, under the agent windows' flash. */
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-crt {
0% {
opacity: 0;
transform: scale3d(0.5, 0.006, 1);
}
22% {
opacity: 1;
transform: scale3d(1, 0.01, 1);
}
58% {
opacity: 1;
transform: scale3d(1, 1.04, 1);
}
80% {
opacity: 1;
transform: scale3d(1, 0.985, 1);
}
100% {
opacity: 1;
transform: none;
}
}
/* Beam down: holds still while a beam draws down from its tab (the entrance
module's own overlay, .tile-beam-line), then materializes out of the agent
windows' green wash. No transform, so the beam lands where the tile is. */
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-beam;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-beam-wash;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-beam {
0% {
opacity: 0;
}
20% {
opacity: 0.55;
}
32% {
opacity: 0.2;
}
48% {
opacity: 0.9;
}
100% {
opacity: 1;
}
}
/* Cascade: swings down from its top edge, a diagonal at a time. */
html[data-tile-anim="cascade"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-cascade;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes tile-enter-cascade {
0% {
opacity: 0;
transform: perspective(1400px) rotateX(-80deg);
}
55% {
opacity: 1;
transform: perspective(1400px) rotateX(8deg);
}
80% {
transform: perspective(1400px) rotateX(-2.5deg);
}
100% {
opacity: 1;
transform: perspective(1400px) rotateX(0deg);
}
}
/* Pop: springs open from its centre, rippling out from the focused tile. */
html[data-tile-anim="pop"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-pop;
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-pop {
0% {
opacity: 0;
transform: scale(0.6);
}
58% {
opacity: 1;
transform: scale(1.03);
}
80% {
transform: scale(0.993);
}
100% {
opacity: 1;
transform: none;
}
}
/* Soft: a slow drift up into place; with the Soft focus theme each screen
then focus-pulls in (the pane's `blur` style on .tile-body). */
html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-soft;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tile-enter-soft {
0% {
opacity: 0;
transform: translateY(16px) scale(0.94);
}
100% {
opacity: 1;
transform: none;
}
}
/* The tab a tile flies (or beams) out of glows as it goes. */
.session-tab.tab-launch:not(.tab-loading)::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
animation: tab-launch-flash calc(520ms * var(--anim-enter-scale, 1)) ease-out var(--tab-launch-delay, 0ms) both;
}
@keyframes tab-launch-flash {
0% {
opacity: 0;
background: color-mix(in srgb, var(--accent, #4a9eff) 40%, transparent);
box-shadow: 0 0 0 1px var(--accent, #4a9eff), 0 0 14px 2px color-mix(in srgb, var(--accent, #4a9eff) 70%, transparent);
}
18% {
opacity: 1;
}
100% {
opacity: 0;
background: transparent;
box-shadow: 0 0 0 1px transparent, 0 0 0 0 transparent;
}
}
/* `beam`'s lines: the connection-line look, drawn from the tab down to the
tile, held while the tile materializes, then faded. Their own overlay
(#tileBeamLines), removed by the entrance module once they are done. */
.connection-line.tile-beam-line {
stroke-dasharray: var(--line-len);
animation:
line-enter-draw calc(380ms * var(--anim-enter-scale, 1)) cubic-bezier(0.32, 0.8, 0.3, 1)
var(--line-enter-delay, 0ms) both,
tile-beam-out calc(420ms * var(--anim-enter-scale, 1)) ease-in
calc(var(--line-enter-delay, 0ms) + 640ms * var(--anim-enter-scale, 1)) forwards;
}
@keyframes tile-beam-out {
from {
opacity: 0.9;
}
to {
opacity: 0;
}
}
.tile-body .xterm {
transition: opacity 160ms ease-out;
}
@@ -20561,220 +20136,9 @@ html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
}
}
/* The entrance style's own way out (entrance-animations.js _stageTileExit),
on the still copy only and only for the Tiles toggle's close: a re-form's
copy (--now) covers tiles that stay, so it keeps the plain fade above.
Copies leave in reading order (--tile-exit-delay), so the last one ends
last and takes the layer with it. */
.tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-fly calc(460ms * var(--anim-enter-scale, 1)) cubic-bezier(0.55, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-return calc(460ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-fly {
0% {
opacity: 0.8;
transform: scale(0.985);
}
75% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -48px))
scale(var(--tile-to-sx, 0.3), var(--tile-to-sy, 0.1));
}
}
@keyframes tile-wash-return {
0% {
opacity: 0;
background: transparent;
}
100% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 55%, transparent);
}
}
html[data-tile-anim="deal"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-deal calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.25)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-deal {
0% {
opacity: 0.8;
transform: scale(0.985);
}
22% {
transform: scale(1.02) rotate(calc(var(--tile-to-rot, -10deg) * -0.15));
}
85% {
opacity: 0.85;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -240px)) scale(var(--tile-to-sx, 0.1))
rotate(var(--tile-to-rot, -10deg));
}
}
/* CRT: the classic switch-off, down to a line, then a dot, then dark. */
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-crt calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.6, 1)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-crt-off calc(520ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-crt {
0% {
opacity: 0.8;
transform: scale(0.985);
}
42% {
opacity: 1;
transform: scale3d(1, 0.012, 1);
}
72% {
opacity: 1;
transform: scale3d(0.03, 0.012, 1);
}
100% {
opacity: 0;
transform: scale3d(0, 0, 1);
}
}
@keyframes tile-wash-crt-off {
0% {
opacity: 0;
background: transparent;
}
38% {
opacity: 1;
background: rgba(220, 255, 235, 0.95);
}
100% {
opacity: 1;
background: rgba(255, 255, 255, 1);
}
}
/* Beam down: beamed back up, flickering out under a rising wash. */
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-beam calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-beam-up calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-beam {
0% {
opacity: 0.8;
}
40% {
opacity: 0.9;
}
55% {
opacity: 0.3;
}
70% {
opacity: 0.6;
}
100% {
opacity: 0;
}
}
@keyframes tile-wash-beam-up {
0% {
opacity: 0;
background: linear-gradient(0deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0));
}
100% {
opacity: 1;
background: linear-gradient(0deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0.2));
}
}
/* Cascade: folds back up on its top edge. */
html[data-tile-anim="cascade"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-cascade calc(480ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
transform-origin: 50% 0%;
}
@keyframes tile-leave-cascade {
0% {
opacity: 0.8;
transform: perspective(1400px) rotateX(0deg) scale(0.985);
}
100% {
opacity: 0;
transform: perspective(1400px) rotateX(82deg) scale(0.985);
}
}
/* Pop: a last swell, then it pops away. */
html[data-tile-anim="pop"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-pop calc(380ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-pop {
0% {
opacity: 0.8;
transform: scale(0.985);
}
35% {
opacity: 0.9;
transform: scale(1.03);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* Soft: sinks away slowly. */
html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-soft calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.7, 0.4)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-soft {
0% {
opacity: 0.8;
transform: scale(0.985);
}
100% {
opacity: 0;
transform: translateY(14px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.tile-count-menu,
.tile.tile--entering,
.tile.tile--entering::before,
.tile.tile--needs::after,
.tile.tile--loading .tile-body::after,
.tile-grid-ghosts .tile.tile--leaving,
+5 -7
View File
@@ -283,14 +283,12 @@ Object.assign(CodemanApp.prototype, {
wizardRect = wizardContent.getBoundingClientRect();
}
// Read tab rects for normal mode (only tabs that are actually needed).
// Only a painted row is cached: a row a search or filter hid has no line
// (_paintedSessionTab), rather than one drawn from the viewport's corner.
// Read tab rects for normal mode (only tabs that are actually needed)
if (!wizardOpen) {
for (const { agentId } of visibleSubagentWindows) {
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
const tab = this._paintedSessionTab(parentSessionId);
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
}
}
@@ -399,7 +397,7 @@ Object.assign(CodemanApp.prototype, {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) {
// Tab not painted (session closed, collapsed group, or hidden by a search)
// Tab not in DOM (might be scrolled out or session closed)
continue;
}
@@ -693,8 +691,8 @@ Object.assign(CodemanApp.prototype, {
}
}
// Get parent TAB element for spawn animation (a hidden row spawns normally)
const parentTab = this._paintedSessionTab(parentSessionId);
// Get parent TAB element for spawn animation
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Create window element
const win = document.createElement('div');
+4 -8
View File
@@ -329,14 +329,10 @@ Object.assign(CodemanApp.prototype, {
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// Group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefMoveActions?.({ kind: 'session', id: 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) }]
: []),
...((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) {
+15 -92
View File
@@ -11,20 +11,14 @@
*
* Deliberately plainer than the primary pane (this.terminal/this._ws in
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
* handlers (a swipe on a touch screen pages nothing), and no keyboard
* accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md
* and docs/tile-grid-plan.md.
* handlers (a swipe on a touch screen pages nothing), no keyboard accessory
* bar, and no SGR wheel forwarding to Claude's fullscreen renderer
* (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see
* docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md.
*
* What it does carry over from the primary pane, through the primary pane's
* own code aimed at THIS pane (its terminal, its session, never the active
* one):
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
* renderer scrolls its own transcript on SGR wheel reports, while this
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
* as reports at the pointer's cell in this pane, through the primary
* 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
@@ -49,7 +43,7 @@
*
* @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 terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick)
* @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
*/
@@ -207,9 +201,6 @@
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
// whole page, carried to the next wheel event.
this._pageKeyPending = 0;
// Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0;
// Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
this._scrollBytes = '';
this._scrollFlushTimer = null;
@@ -1036,18 +1027,16 @@
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Not passive: the three
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
// while the pane still has scrollback to scroll. Not passive: the one route
// this pane takes over, paging a hollow buffer's CLI transcript
// (_maybePageCliTranscript), is consumed right here (preventDefault plus
// stopPropagation in the capture phase, the primary pane's technique), so
// xterm's viewport, a descendant, never sees them. Every other wheel is left
// xterm's viewport, a descendant, never sees it. Every other wheel is left
// to xterm, which keeps doing the scrolling, and only observed for the
// shell history pull.
_installWheelListener() {
this._onWheel = (ev) => {
if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
if (this._maybePageCliTranscript(ev)) {
ev.preventDefault();
ev.stopPropagation();
return;
@@ -1057,71 +1046,6 @@
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
}
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
// no history for such a pane). Left to xterm, the wheel dragged those stale
// frames, Claude's pinned input box with them, up the tile, or scrolled
// nothing at all. The 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 reports go to this
// pane's session through its own coalescer. Returns true when the wheel
// belongs to the CLI: a gesture with no whole line or no measurable cell is
// consumed too, as in the primary pane, so xterm never scrolls the stale
// frames under a forwarding session. Shift fails the gate, so Shift+wheel
// still scrolls the local scrollback (_maybeScrollLocalOnShift).
_maybeForwardWheelToCli(ev) {
if (this._destroyed || !this.terminal || !ev) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
// SGR coordinates address the live screen, so a report from a scrolled-up
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
const bytes = input.sgrWheelReports(lines, pos);
if (bytes) this._queueScrollBytes(bytes);
return true;
}
// Shift+wheel scrolls this xterm's local scrollback, the explicit "local
// history" gesture, here as in the primary pane (whose capture-phase wheel
// handler scrolls with terminal.scrollLines() for the same reason). Left to
// xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a
// HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical
// wheel into a horizontal one, so the viewport never moved. Reads the
// dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder
// for the next event (a trackpad's small deltas), and on the way up still
// asks a shell pane for more history. Returns true when the wheel was
// consumed here.
_maybeScrollLocalOnShift(ev) {
if (this._destroyed || !this.terminal || !ev?.shiftKey) return false;
const input = global.CodemanTerminalInput;
if (!input?.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows);
const lines = Math.trunc(total);
this._shiftScrollPending = total - lines;
if (lines) {
this.terminal.scrollLines(lines);
if (lines < 0) this._maybeLoadMoreHistory();
}
return true;
}
// A plain left-click reported to the CLI, the primary pane's desktop click
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
@@ -1163,9 +1087,9 @@
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
const target = { terminal: this.terminal, sessionId: this.sessionId };
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
// forwarding session is never paged.
// The primary pane would forward this wheel to Claude's fullscreen
// renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md
// follow-up 4), so the wheel stays with xterm, as before.
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
// Only from the live screen. The one gate the primary pane never needs: a
@@ -1187,9 +1111,8 @@
return true;
}
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
// that keeps scrolling after it stops. A narrow
// Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a
// fling cannot build a backlog that keeps paging after it stops. A narrow
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
// (terminal-ui.js; keep the two in step), which flushes to the active
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
+30 -60
View File
@@ -107,32 +107,6 @@
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
}
// The same travel rounded to whole lines for the SGR wheel reports: a pure
// horizontal swipe is 0 (nothing to send), and anything else moves at least
// one line, so the small pixel deltas of a precision touchpad still scroll.
// The body of the primary pane's _wheelScrollLines.
function wheelDeltaWholeLines(ev, rows) {
const lines = wheelDeltaLines(ev, rows);
if (!lines) return 0;
return Math.round(lines) || (lines > 0 ? 1 : -1);
}
// Ticks one gesture batch may report: Claude applies its own scroll-speed
// multiplier and acceleration on top, so a bigger batch only overshoots.
const SGR_WHEEL_MAX_TICKS = 5;
// Whole wheel lines → SGR wheel reports at a 1-based cell `pos` ({ col, row },
// live-screen relative): button 64 per line up, 65 per line down, capped at
// SGR_WHEEL_MAX_TICKS. '' when there is nothing to send. The encoding of the
// primary pane's _sendSyntheticSgrWheel, pure so a TerminalTile forwards
// byte-identical reports to its own session.
function sgrWheelReports(lines, pos) {
if (!lines || !pos) return '';
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), SGR_WHEEL_MAX_TICKS);
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// 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
@@ -203,18 +177,6 @@
}
}
// Screen row of the cursor, for the local-echo prompt finders: xterm's cursorY is
// baseY-relative, so a viewport parked above the bottom shifts it. Null when off screen.
function cursorViewportRow(terminal) {
try {
const buf = terminal.buffer.active;
const row = buf.baseY + (buf.cursorY || 0) - buf.viewportY;
return row >= 0 && row < terminal.rows ? row : null;
} catch {
return null;
}
}
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
}
@@ -288,7 +250,6 @@
isComposerNavKey,
classifyPredictInput,
isCodexComposerRow,
cursorViewportRow,
CODEX_COMPOSER_ROW_RE,
BRACKETED_PASTE_START,
USER_SCROLL_STICKY_SUPPRESS_MS,
@@ -299,9 +260,6 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
wheelDeltaWholeLines,
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
pageKeysForTravel,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
@@ -2046,7 +2004,7 @@ Object.assign(CodemanApp.prototype, {
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
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
// included so pasted-attachment paths (`.claude-images/paste-*.png`) and
// screenshots an agent just wrote are clickable; those open the file
// preview rather than the log viewer (see addLink).
//
@@ -3669,10 +3627,24 @@ 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;
const sessionId = this.activeSessionId;
Promise.resolve(this._maybeRefetchFullHistory?.())
.catch(() => {})
.then(() => {
if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId);
});
},
/**
@@ -4033,15 +4005,14 @@ Object.assign(CodemanApp.prototype, {
if (session.mode === 'opencode') {
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
// We use the cursor's screen row to find the right line, then scan for ┃.
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 3,
find: (terminal) => {
try {
const buf = terminal.buffer.active;
const row = window.CodemanTerminalInput.cursorViewportRow(terminal);
if (row === null) return null;
const row = buf.cursorY;
const line = buf.getLine(buf.viewportY + row);
if (!line) return null;
const text = line.translateToString(true);
@@ -4071,25 +4042,23 @@ Object.assign(CodemanApp.prototype, {
// the viewport, while xterm's cursor still marks the editable input
// position. Fall back to cursor coordinates so phone typing appears at
// the terminal cursor instead of disappearing into pending state.
// The glyph is looked for from the cursor's screen row up to the top of the
// live screen only: rows above that are parked scrollback with old composer glyphs.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 0,
find: (terminal) => {
try {
const buf = terminal.buffer.active;
const cursorRow = window.CodemanTerminalInput.cursorViewportRow(terminal);
if (cursorRow === null) return null;
const lowest = Math.max(0, buf.baseY - buf.viewportY);
for (let row = cursorRow; row >= lowest; row--) {
for (let row = terminal.rows - 1; row >= 0; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
const idx = text.lastIndexOf('\u276f');
if (idx >= 0) return { row, col: idx + 2 };
}
return { row: cursorRow, col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)) };
return {
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
};
} catch {
return null;
}
@@ -5608,8 +5577,9 @@ Object.assign(CodemanApp.prototype, {
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
// Claude mode the same value also capped the forwarded SGR report at one tick.
_wheelScrollLines(ev) {
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
const lines = this._wheelScrollLinesFloat(ev);
if (!lines) return 0; // pure horizontal swipe: don't fall through to -1
return Math.round(lines) || (lines > 0 ? 1 : -1);
},
/** Unrounded variant for the smooth local-scroll path, which accumulates
@@ -5683,13 +5653,13 @@ Object.assign(CodemanApp.prototype, {
// scroll-speed multiplier and acceleration on top), and the queue is bounded
// so a wild scroll can't build a backlog that keeps scrolling after the finger
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), 5);
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
},
/**
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -194,8 +194,8 @@ Object.assign(CodemanApp.prototype, {
</div>
`;
// Position: spawn from the parent tab if it is painted, else cascade.
const parentTab = this._paintedSessionTab(parentSessionId);
// Position: spawn from the parent tab if we can find it, else cascade.
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
if (parentTab) {
// _tabAnchor() puts the spawn point below the tab in header layout and to
// the RIGHT of it in sidebar layout, so the window never lands on the
@@ -312,12 +312,9 @@ Object.assign(CodemanApp.prototype, {
});
},
/**
* Genie the window toward the center of its tab, then invoke `done` to tear it
* down. A tab that is not painted (hidden by a search) tears down at once.
*/
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
_animateUltracodeWindowToTab(element, sessionId, done) {
const tab = this._paintedSessionTab(sessionId);
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
if (!tab || !element) {
done();
return;
@@ -784,7 +781,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = this._paintedSessionTab(parentSessionId);
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
@@ -833,7 +830,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = this._paintedSessionTab(parentSessionId);
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
const tabRect = rects.get(tabKey);
+7 -92
View File
@@ -29,11 +29,7 @@
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
*/
import { execFile, spawn } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
import { spawn } from 'node:child_process';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
@@ -211,65 +207,14 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
*/
const installsInFlight = new Set<string>();
const execFileAsync = promisify(execFile);
/**
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
*
* Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on
* timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is
* judged by the nearest ancestor that does: npm creates the missing directories, so a user
* `.npmrc` pointing at `~/.npm-global` before it was made is not moved.
*/
export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> {
try {
const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], {
env: source,
encoding: 'utf8',
timeout: 5_000,
killSignal: 'SIGKILL',
});
const prefix = stdout.trim();
if (!prefix) return true;
// npm creates lib/node_modules under the prefix; walk up to the first directory that exists.
let dir = join(prefix, 'lib', 'node_modules');
for (;;) {
try {
await access(dir, fsConstants.W_OK);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
const parent = dirname(dir);
if (parent === dir) return false;
dir = parent;
}
}
} catch {
return true;
}
}
/**
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
* data dir, the tmux socket), none of which an installer needs.
*
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
* update (the image's own prefix is image content);
* - on a native install whose npm global prefix is not writable by the server user (a system node
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
* so an `npm install -g` lands on the persistent home mount instead of the image.
*/
export function installEnv(
source: NodeJS.ProcessEnv = process.env,
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true
): NodeJS.ProcessEnv {
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('CODEMAN_')) env[key] = value;
@@ -279,40 +224,11 @@ export function installEnv(
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
redirectNpmPrefix(env, source.HOME);
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
redirectNpmPrefix(env, source.HOME);
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
}
return env;
}
/**
* Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a
* lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings
* are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit
* operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one.
*/
function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void {
for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key];
env.NPM_CONFIG_PREFIX = `${home}/.local`;
}
/**
* `installEnv` for this process, with the (async) npm prefix probe done first and only when the
* command runs npm at all: a `curl | bash` installer never pays for it.
*/
async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> {
const usesNpm = /\bnpm\b/.test(command);
const probeNeeded =
usesNpm &&
source.CODEMAN_IN_CONTAINER !== '1' &&
process.platform !== 'win32' &&
!!source.HOME &&
!source.NPM_CONFIG_PREFIX;
const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true;
return installEnv(source, () => writable);
}
interface InstallResult {
code: number | null;
output: string;
@@ -328,7 +244,6 @@ interface InstallResult {
* CUSTOM entry can never reach this function at all — see the route's own guard below.
*/
async function runInstallCommand(command: string): Promise<InstallResult> {
const env = await installEnvFor(command);
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
@@ -340,7 +255,7 @@ async function runInstallCommand(command: string): Promise<InstallResult> {
// out into package-manager children, and spawn's own `timeout` option signals
// only the direct child, leaving survivors holding the pipes open forever.
detached: true,
env,
env: installEnv(),
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
+3 -10
View File
@@ -10,8 +10,7 @@
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
* or when its config file exists); one that is enabled but absent from the machine is reported
* or already has its config file; one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
@@ -29,7 +28,6 @@ import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
import { mcpSyncOnlyTargets } from '../../mcp-sync-targets.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
@@ -37,13 +35,9 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
return settings.mcpSyncEnabled === true;
}
/**
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
* definition wins a same-name difference.
*/
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
const registry = enabledClis()
return enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
@@ -52,7 +46,6 @@ export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTa
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
return [...registry, ...mcpSyncOnlyTargets(new Set(registry.map((t) => t.id)))];
}
/**
+1 -1
View File
@@ -275,7 +275,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
// count this session's historical tokens into the lifetime totals, demote
// a pinned record to `stopped` (which this pass reads as an intentional
// kill, making the session permanently unrestorable) and delete the
// workspace's `.codeman-uploads`. This undoes only the construction.
// workspace's `.claude-images`. This undoes only the construction.
await ctx
.discardPartiallyBuiltSession(entry.sessionId)
.catch((discardErr: unknown) =>
+37 -65
View File
@@ -188,7 +188,6 @@ import {
} from '../response-viewer-transcript.js';
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
import { UPLOADS_DIR } from '../paste-image-gc.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -362,51 +361,6 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
}
}
/**
* Create or re-verify `{workingDir}/.codeman-uploads` for a prompt upload, or
* null when something other than a regular directory sits there. An agent or
* postinstall script could plant `.codeman-uploads -> ~/.ssh/` and redirect
* future writes outside workingDir: lstat (not stat) sees the symlink itself,
* and mkdir without `recursive` does not follow one for the leaf either;
* O_EXCL|O_NOFOLLOW on the file open makes the write itself symlink-safe.
* The folder ignores itself: a `.gitignore` of `*`, written once with O_EXCL,
* so the user's repository never sees uploads, their own ignore file is never
* touched, and a file already there is theirs and stays as it is.
*/
async function ensureUploadDir(workingDir: string): Promise<string | null> {
// workingDir is guaranteed to exist (live session).
const uploadDir = join(workingDir, UPLOADS_DIR);
try {
const dirStat = await fs.lstat(uploadDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return null;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
try {
await fs.mkdir(uploadDir);
} catch (mkErr: unknown) {
// Concurrent uploads (a batch of photos) race to create the dir — the
// losers get EEXIST. Treat an already-present REAL directory as success,
// but re-verify it isn't a symlink a racing actor planted.
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
const raceStat = await fs.lstat(uploadDir);
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) return null;
}
}
const ignoreFile = join(uploadDir, '.gitignore');
try {
// 'wx' = O_CREAT|O_EXCL, which also fails on a symlink at the path.
await fs.writeFile(ignoreFile, '*\n', { flag: 'wx' });
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return uploadDir;
// The create can succeed before the write fails (ENOSPC): an empty ignore
// file would read as the user's on the next upload, so take it back; its own
// failure must not replace the cause.
await fs.rm(ignoreFile, { force: true }).catch(() => {});
throw err;
}
return uploadDir;
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys.
@@ -3271,6 +3225,12 @@ export function registerSessionRoutes(
// damage that does not exist.
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
// Rows tmux holds above the visible frame, which is the most a `full=1`
// pull can add. `truncated` measures the BYTE stream, and for a pane that
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
// a tail cut drops are old repaints that no request can bring back, so a
// client must not offer to load them. Absent when the pane was not read.
paneHistoryLines: hasLiveMuxBuffer ? captureOpts.capturedHistoryLines : undefined,
};
});
@@ -5244,20 +5204,6 @@ export function registerSessionRoutes(
const session = findSessionOrFail(ctx, id, req);
// The file lands on THIS host under the session's working directory, which
// for a remote (SSH) session is the remote path: the agent there could never
// read it, and the write would land in a same-named local directory or fail.
// An owned Docker case is fine, its workspace is bind-mounted at the same
// absolute path; an adopted container (owned: false) mounts nothing, so its
// agent reads the file only if the container exposes that host path.
if (session.remote) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Prompt uploads are not supported for remote (SSH) sessions'
);
}
if (!req.isMultipart()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -5354,11 +5300,37 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
// Save to {workingDir}/.claude-images/
// Refuse symlinks at imageDir — an agent or postinstall script could plant
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
// We lstat (not stat) so we see the symlink itself. Use mkdir without
// `recursive` so the leaf creation does not follow a symlink either, and
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
const imageDir = join(session.workingDir, '.claude-images');
try {
const dirStat = await fs.lstat(imageDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// Non-recursive mkdir: does not follow symlinks for the leaf.
// session.workingDir is guaranteed to exist (live session).
try {
await fs.mkdir(imageDir);
} catch (mkErr: unknown) {
// Concurrent uploads (a batch of photos) race to create .claude-images —
// the losers get EEXIST. Treat an already-present REAL directory as
// success, but re-verify it isn't a symlink a racing actor planted
// (preserve the symlink-safety guarantee above).
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
const raceStat = await fs.lstat(imageDir);
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
}
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
-7
View File
@@ -1391,13 +1391,6 @@ export const SettingsUpdateSchema = z
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
/**
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
* false hides the agent logo on session tabs and the desktop home rail
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
* and the Run menus keep their logos.
*/
showTabCliLogos: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
tabRailDetail: z.enum(['simple', 'rich']).optional(),
+9 -14
View File
@@ -33,11 +33,11 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname, uptime as osUptime } from 'node:os';
@@ -1546,16 +1546,11 @@ export class WebServer extends EventEmitter {
killing: this.killingSessions,
})
) {
// Both upload dirs, the pre-move one too; uploadDirs() lists only real
// directories that are not the data dir and do not contain it, none for a
// remote session, and nothing on a workspace whose bounded probe did not
// answer (pastCap: this acts on one path at the user's request).
for (const uploadDir of await uploadDirs(session, { pastCap: true })) {
try {
await fs.rm(uploadDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
const pasteImageDir = join(session.workingDir, '.claude-images');
try {
rmSync(pasteImageDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
}
// Drop the agent skill's preamble cache for this session (seeded at create).
@@ -2916,7 +2911,7 @@ export class WebServer extends EventEmitter {
}
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's upload dirs hourly.
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
@@ -3368,7 +3363,7 @@ export class WebServer extends EventEmitter {
* path adds the session's token totals to the lifetime figures, demotes a
* pinned record to `stopped` (the durable marker of an intentional kill, which
* would make the session permanently ineligible for a reboot restore), drops
* the persisted Ralph state, and recursively removes the upload dirs from the
* the persisted Ralph state, and recursively removes `.claude-images` from the
* WORKING DIRECTORY, which belongs to the workspace rather than to this session
* and may hold another live session's pasted images.
*
+4 -3
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Phase 5 admin API tests (live server, ephemeral port).
* @fileoverview Phase 5 admin API tests (live server, port 3173).
*
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
* create (one-time password), patch + last-admin invariant, reset-password,
@@ -16,8 +16,9 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const PORT = 3173;
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const url = (p: string) => `http://localhost:${PORT}${p}`;
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
@@ -47,7 +48,7 @@ beforeAll(async () => {
invalidateUsersCache();
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
server = new WebServer(0, false, true);
server = new WebServer(PORT, false, true);
await server.start();
});
-25
View File
@@ -80,31 +80,6 @@ describe('App Settings modal structure', () => {
expect(system).toContain('id="appSettingsTunnelEnabled"');
});
/**
* Owner decision (2026-10-09): every animation setting has its own
* Animations section, right after Appearance, so it is easy to find. The
* selects are wired by id in entrance-animations.js, not by the load/save
* path above, so they get their own check here.
*/
it('keeps every animation setting in its own Animations section, after Appearance', () => {
const modal = settingsModal();
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
for (const list of [rail, order]) {
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
}
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
}
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(anim).toContain(`document.getElementById('${id}')`);
}
});
it('keeps Local Echo the first row of the second section', () => {
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
+4 -4
View File
@@ -8,12 +8,14 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
const PORT = 3197;
describe('reverse-proxy base path: server wiring', () => {
let server: WebServer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let app: any;
beforeAll(async () => {
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
server = new WebServer(PORT, false, true, '127.0.0.1', undefined, false, '/codeman');
await server.start();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app = (server as any).app;
@@ -64,9 +66,7 @@ describe('reverse-proxy base path: server wiring', () => {
const { WebSocket } = await import('ws');
const close = (path: string) =>
new Promise<{ code: number; reason: string }>((resolve) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, {
headers: { origin: `http://127.0.0.1:${server.boundPort}` },
});
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { headers: { origin: `http://127.0.0.1:${PORT}` } });
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
ws.on('error', (e) => resolve({ code: -1, reason: String(e) }));
});
+5 -5
View File
@@ -20,7 +20,7 @@
* because the mismatch itself needs two viewports to stage against live tmux.
* Without the fix the first assertion below sees one fetch instead of two.
*
* Port: ephemeral
* Port: 3252 (capture geometry retry)
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
*/
@@ -29,15 +29,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
let baseUrl: string;
const PORT = 3252;
const BASE_URL = `http://localhost:${PORT}`;
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true); // testMode
server = new WebServer(PORT, false, true); // testMode
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 60_000);
@@ -176,7 +176,7 @@ async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; url
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
async function openSession(page: Page): Promise<string> {
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
// Without it `app.terminal.rows` reads 0 and every height comparison below
+5 -5
View File
@@ -14,7 +14,7 @@
* own `json()` call, which is the one place guaranteed to land after the
* headers and before the chunked write.
*
* Port: ephemeral
* Port: 3256 (capture load window)
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
*/
@@ -23,16 +23,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
let baseUrl: string;
const PORT = 3256;
const BASE_URL = `http://localhost:${PORT}`;
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true); // testMode
server = new WebServer(PORT, false, true); // testMode
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 60_000);
@@ -115,7 +115,7 @@ async function runLoad(page: Page, sessionId: string, source: string): Promise<n
}
async function openSession(page: Page): Promise<string> {
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm loads from /vendor, so the terminal appears a beat after the app.
// Without it every buffer assertion below would throw rather than compare.
+4 -2
View File
@@ -8,6 +8,8 @@ import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
const PORT = 3193;
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
@@ -16,11 +18,11 @@ describe('Create a case in a custom folder', () => {
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
server = new WebServer(0, false, true);
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
-711
View File
@@ -1,711 +0,0 @@
/**
* @fileoverview `codeman agent …` — the three invariants from `src/cli-agent.ts`
* plus every verb against a recording fake transport, and the real HTTP transport
* against a local server (headers, auth, query encoding).
*/
import http from 'node:http';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
EXIT,
agentInterrupt,
agentLs,
agentRead,
agentRm,
agentSend,
agentSpawn,
agentWait,
baseHeaders,
sendPromptFromArgs,
composerReadyMark,
buildInterruptBody,
buildSendBody,
deleteRefusal,
describeFailure,
httpRequest,
inputRefusal,
isSelfSession,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
registerAgentCommands,
resolveAgentContext,
stripAnsi,
waitExitCode,
type AgentContext,
type AgentDeps,
type ApiResponse,
type RequestOptions,
} from '../src/cli-agent.js';
import { readCodemanEnvFile } from '../src/codeman-credentials.js';
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
function ctx(overrides: Partial<AgentContext> = {}): AgentContext {
return { apiUrl: 'http://127.0.0.1:1', selfId: SELF, ...overrides };
}
/** Recording transport: answers from a queue (or a resolver) and keeps every call. */
function fakeDeps(
answer: ((options: RequestOptions) => ApiResponse) | ApiResponse[],
json = false
): AgentDeps & { calls: RequestOptions[]; out: string[]; err: string[] } {
const calls: RequestOptions[] = [];
const out: string[] = [];
const err: string[] = [];
const queue = Array.isArray(answer) ? [...answer] : undefined;
return {
ctx: ctx(),
calls,
out,
err,
json,
now: () => 1_700_000_000_000,
io: { out: (l) => out.push(l), err: (l) => err.push(l) },
request: async (_c, options) => {
calls.push(options);
if (queue) {
const next = queue.shift();
if (!next) throw new Error('fake transport: no answer queued');
return next;
}
return (answer as (o: RequestOptions) => ApiResponse)(options);
},
};
}
function ok(data: unknown): ApiResponse {
return { status: 200, json: { success: true, data }, text: '' };
}
function apiError(status: number, errorCode: string, error: string): ApiResponse {
return { status, json: { success: false, errorCode, error }, text: '' };
}
// ─────────────────────────────────────────────────────────────────────────────
// Invariant 1: the guard
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveAgentContext (guard)', () => {
const inside = { CODEMAN_MUX: '1', CODEMAN_API_URL: 'http://127.0.0.1:3459', CODEMAN_SESSION_ID: SELF };
it('refuses outside a Codeman session', () => {
expect(() => resolveAgentContext({}, () => ({}))).toThrow(AgentGuardError);
expect(() => resolveAgentContext({ ...inside, CODEMAN_MUX: '0' }, () => ({}))).toThrow(/CODEMAN_MUX/);
});
it('never guesses an API URL', () => {
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: '' }, () => ({}))).toThrow(/refusing to guess/);
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: undefined }, () => ({}))).toThrow(AgentGuardError);
});
it('needs its own session id to tell self from others', () => {
expect(() => resolveAgentContext({ ...inside, CODEMAN_SESSION_ID: '' }, () => ({}))).toThrow(/CODEMAN_SESSION_ID/);
});
it('takes the password from the environment first, the .env file second, and none means open', () => {
expect(
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_PASSWORD: 'file' })).auth
).toEqual({
username: 'admin',
password: 'pw',
});
expect(resolveAgentContext(inside, () => ({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 'file' })).auth).toEqual({
username: 'joe',
password: 'file',
});
expect(resolveAgentContext(inside, () => ({})).auth).toBeUndefined();
});
it('resolves each field the way attach and the TUI do (one shared order)', () => {
// Password from the environment, username from the file: joe, not admin.
expect(
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_USERNAME: 'joe' })).auth
).toEqual({ username: 'joe', password: 'pw' });
});
it('reads a hand-authored .env with quotes and export prefixes', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-agent-env-'));
try {
const file = join(dir, '.env');
writeFileSync(file, '# comment\nexport CODEMAN_USERNAME="joe"\nCODEMAN_PASSWORD=\'s3cret\'\nnot a line\n');
expect(readCodemanEnvFile(file)).toEqual({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 's3cret' });
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Invariant 2: send is printable text + \r; ESC lives only in interrupt
// ─────────────────────────────────────────────────────────────────────────────
describe('send transmits printable text only', () => {
it('refuses every control byte and DEL, naming it', () => {
expect(inputRefusal('\u0003')).toMatch(/0x03/); // Ctrl+C: opencode's app_exit
expect(inputRefusal('ls\u001b')).toMatch(/0x1b/);
expect(inputRefusal('a\u007fb')).toMatch(/0x7f/);
expect(inputRefusal('two\nlines')).toMatch(/single line/); // not the ESC hint
expect(inputRefusal('a\tb')).toMatch(/single line/);
expect(inputRefusal('x\u009bmy')).toMatch(/0x9b/); // 8-bit CSI
expect(inputRefusal('')).toMatch(/empty/);
});
it('accepts ordinary prompts, including unicode', () => {
expect(inputRefusal('review the diff in src/, then say DONE_4711')).toBeUndefined();
expect(inputRefusal('prüfe die Ändërung ❯ ok')).toBeUndefined();
});
it('appends exactly one \\r, or nothing with --no-enter, and never anything else', () => {
const base = { clientId: 'c', seq: 1 };
expect(buildSendBody('hi', { ...base, enter: true }).input).toBe('hi\r');
expect(buildSendBody('hi', { ...base, enter: false }).input).toBe('hi');
const body = buildSendBody('hi', { ...base, enter: true, wait: 'stop,exit', waitTimeout: 5000 });
expect(body).toEqual({ input: 'hi\r', useMux: true, clientId: 'c', seq: 1, wait: 'stop,exit', waitTimeout: 5000 });
expect(buildSendBody('hi', { ...base, enter: true })).not.toHaveProperty('wait');
});
it('interrupt is a bare ESC with no Enter', () => {
const body = buildInterruptBody('c-interrupt', 7);
expect(body.input).toBe('\u001b');
expect(String(body.input)).not.toContain('\r');
expect(body).toEqual({ input: '\u001b', useMux: true, clientId: 'c-interrupt', seq: 7 });
});
it('agentSend refuses control bytes BEFORE touching the transport', async () => {
const deps = fakeDeps([]);
expect(await agentSend(deps, { id: OTHER, text: 'q\u0003', enter: true })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Invariant 3: rm fails closed
// ─────────────────────────────────────────────────────────────────────────────
describe('rm fails closed', () => {
it('refuses an empty id, a short self id, and a prefix match in either direction', () => {
expect(deleteRefusal(SELF, '')).toMatch(/empty/);
expect(deleteRefusal('058ee7', OTHER)).toMatch(/too short/);
expect(deleteRefusal(SELF, SELF)).toMatch(/is me/);
expect(deleteRefusal(SELF, SELF.slice(0, 8))).toMatch(/is me/); // 8-char form of me
expect(deleteRefusal(SELF.slice(0, 8), SELF)).toMatch(/is me/); // Docker's truncated $SELF
expect(deleteRefusal(SELF, OTHER)).toBeUndefined();
expect(deleteRefusal(SELF, OTHER.slice(0, 8))).toBeUndefined();
});
it('isSelfSession treats an unprovable self as "maybe me"', () => {
expect(isSelfSession('short', OTHER)).toBe(true);
expect(isSelfSession(SELF, '')).toBe(true);
expect(isSelfSession(SELF, OTHER)).toBe(false);
});
it('agentRm never calls DELETE on a refusal', async () => {
const deps = fakeDeps([]);
expect(await agentRm(deps, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('agentRm deletes a foreign id plainly (killMux default, no query)', async () => {
const deps = fakeDeps([ok({})]);
expect(await agentRm(deps, { id: OTHER })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ method: 'DELETE', path: `/api/v1/sessions/${OTHER}` });
expect(deps.calls[0].query).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Verbs against the fake transport
// ─────────────────────────────────────────────────────────────────────────────
describe('agent ls', () => {
const sessions = [
{ id: SELF, mode: 'claude', status: 'busy', name: 'w1-Codeman' },
{ id: OTHER, mode: 'opencode', status: 'idle', workingDir: '/home/joe/wiki' },
];
it('marks this session and falls back to workingDir for the name', async () => {
const deps = fakeDeps([ok(sessions)]);
expect(await agentLs(deps)).toBe(EXIT.ok);
const text = deps.out.join('\n');
expect(text).toMatch(/\*\s+058ee7b5\s+claude\s+busy\s+w1-Codeman/);
expect(text).toMatch(/94990c6d\s+opencode\s+idle\s+\/home\/joe\/wiki/);
});
it('--json is the envelope data plus a self flag', async () => {
const deps = fakeDeps([ok(sessions)], true);
await agentLs(deps);
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; self: boolean }>;
expect(parsed.map((s) => [s.id.slice(0, 8), s.self])).toEqual([
['058ee7b5', true],
['94990c6d', false],
]);
});
it('surfaces a plain-text 401 as a credentials hint, not a parse error', async () => {
const deps = fakeDeps([{ status: 401, text: 'Unauthorized' }]);
expect(await agentLs(deps)).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/401.*password/);
});
});
describe('agent send', () => {
it('refuses to type into its own composer', async () => {
const deps = fakeDeps([]);
expect(await agentSend(deps, { id: SELF.slice(0, 8), text: 'hi', enter: true })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('fire-and-forget: input + \\r, a fixed clientId per caller, seq from the clock', async () => {
const deps = fakeDeps([ok({ delivered: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'say DONE_1', enter: true })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/input`,
body: { input: 'say DONE_1\r', useMux: true, clientId: 'codeman-agent-cli-058ee7b5', seq: 1_700_000_000_000 },
});
expect(deps.calls[0].body).not.toHaveProperty('wait');
});
it('--wait passes the signal list and timeout through and maps the result to an exit code', async () => {
const stop = fakeDeps([ok({ delivered: true, wait: { signal: 'stop', timedOut: false } })]);
expect(await agentSend(stop, { id: OTHER, text: 'go', enter: true, wait: 'stop,exit', timeoutMs: 5000 })).toBe(
EXIT.ok
);
expect(stop.calls[0].body).toMatchObject({ wait: 'stop,exit', waitTimeout: 5000 });
const timeout = fakeDeps([ok({ delivered: true, wait: { timedOut: true, timeoutMs: 5000 } })]);
expect(await agentSend(timeout, { id: OTHER, text: 'go', enter: true, wait: true, timeoutMs: 5000 })).toBe(
EXIT.timeout
);
const dead = fakeDeps([ok({ delivered: true, wait: { signal: 'exit' } })]);
expect(await agentSend(dead, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
});
it('delivered:false without duplicate is "the bytes went nowhere": exit 3, never a ✓', async () => {
const deps = fakeDeps([ok({ delivered: false, duplicate: false, wait: { ended: true, signal: null } })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
expect(deps.out.join('')).not.toMatch(/delivered to/);
expect(deps.err.join('')).toMatch(/not delivered.*restart/);
});
it('fire-and-forget says "accepted", not "delivered" (the route answers before the write)', async () => {
const deps = fakeDeps([ok({})]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
expect(deps.out.join('')).toMatch(/accepted for/);
expect(deps.out.join('')).not.toMatch(/delivered to/);
});
it('a sleeping remote host: `buffered` gets its own line and exit 0, never "accepted"', async () => {
const deps = fakeDeps([ok({ buffered: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
expect(deps.out.join('')).toMatch(/buffered for .*asleep/);
expect(deps.out.join('')).not.toMatch(/accepted for/);
});
it('`dropped` (over the wake buffer cap) is a failure: exit 1, nothing claims success', async () => {
const deps = fakeDeps([ok({ buffered: true, dropped: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/dropped: .*nothing will be typed/);
expect(deps.out).toEqual([]);
const json = fakeDeps([ok({ buffered: true, dropped: true })], true);
expect(await agentSend(json, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
expect(JSON.parse(json.out.join(''))).toEqual({ buffered: true, dropped: true });
});
it('reports a tagged duplicate instead of claiming delivery', async () => {
const deps = fakeDeps([ok({ delivered: false, duplicate: true })]);
await agentSend(deps, { id: OTHER, text: 'go', enter: true });
expect(deps.out.join('')).toMatch(/duplicate/);
});
});
describe('agent wait', () => {
it('--until goes to /wait and a 400 for a hook-less mode is passed through, not papered over', async () => {
const deps = fakeDeps([apiError(400, 'INVALID_INPUT', 'until=stop is not available for mode opencode')]);
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.calls[0]).toMatchObject({
method: 'GET',
path: `/api/v1/sessions/${OTHER}/wait`,
query: { until: 'stop', timeout: 1000 },
});
expect(deps.err.join('')).toMatch(/INVALID_INPUT.*opencode/);
});
it('--match goes to /wait-output with from=buffer by default', async () => {
const deps = fakeDeps([ok({ wait: { matched: true, match: 'DONE_1', snippet: 'DONE_1' } })]);
expect(await agentWait(deps, { id: OTHER, match: 'DONE_1', timeoutMs: 1000 })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
path: `/api/v1/sessions/${OTHER}/wait-output`,
query: { match: 'DONE_1', from: 'buffer', timeout: 1000 },
});
});
it('refuses --until together with --match', async () => {
const deps = fakeDeps([]);
expect(await agentWait(deps, { id: OTHER, until: 'idle', match: 'x', timeoutMs: 1000 })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('a wait that ended without an answer is reported as dead, not as `signal: null`', async () => {
const deps = fakeDeps([ok({ wait: { ended: true, signal: null, timedOut: false } })]);
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.dead);
expect(deps.out.join('')).toMatch(/went away/);
expect(deps.out.join('')).not.toMatch(/signal: null/);
});
it('exit codes: matched/signal 0, timeout 2, exit 3', () => {
expect(waitExitCode({ signal: 'stop' })).toBe(EXIT.ok);
expect(waitExitCode({ matched: true })).toBe(EXIT.ok);
expect(waitExitCode({ matched: false, timedOut: true })).toBe(EXIT.timeout);
expect(waitExitCode({ timedOut: true })).toBe(EXIT.timeout);
expect(waitExitCode({ signal: 'exit' })).toBe(EXIT.dead);
// A worker that dies during --until stop: the registry only satisfies waiters that
// listed `exit`, then cancels the rest → ended:true, signal:null. Never "done".
expect(waitExitCode({ ended: true, signal: null, timedOut: false })).toBe(EXIT.dead);
expect(waitExitCode({ ended: true, matched: false, timedOut: false })).toBe(EXIT.dead);
expect(waitExitCode(undefined)).toBe(EXIT.error);
});
});
describe('agent read', () => {
it('defaults to last-response and prints the text', async () => {
const deps = fakeDeps([ok({ text: 'the answer', timestamp: 't' })]);
expect(await agentRead(deps, { id: OTHER })).toBe(EXIT.ok);
expect(deps.calls[0].path).toBe(`/api/v1/sessions/${OTHER}/last-response`);
expect(deps.out).toEqual(['the answer']);
});
it('says why an empty transcript is empty instead of printing nothing', async () => {
const deps = fakeDeps([ok({ text: '' })]);
await agentRead(deps, { id: OTHER });
expect(deps.err.join('')).toMatch(/nothing answered yet.*--tail 3000/);
});
it('--tail fetches the terminal and strips ANSI', async () => {
const deps = fakeDeps([ok({ terminalBuffer: '\u001b]0;w1 title\u0007\u001b[32m❯\u001b[0m ready \u001b(B' })]);
expect(await agentRead(deps, { id: OTHER, tail: 500 })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ path: `/api/v1/sessions/${OTHER}/terminal`, query: { tail: 500 } });
expect(deps.out).toEqual(['❯ ready ']);
});
it('--full prints every message with its role', async () => {
const deps = fakeDeps([
ok({
text: 'b',
messages: [
{ role: 'user', text: 'a' },
{ role: 'assistant', text: 'b' },
],
}),
]);
await agentRead(deps, { id: OTHER, full: true });
expect(deps.calls[0].query).toMatchObject({ context: 'full' });
expect(deps.out.map(stripAnsi)).toEqual(['user: a', 'assistant: b']);
});
});
describe('agent interrupt', () => {
it('sends the bare ESC body under its own clientId, never to itself', async () => {
const deps = fakeDeps([ok({ delivered: true })]);
expect(await agentInterrupt(deps, { id: OTHER })).toBe(EXIT.ok);
expect(deps.calls[0].body).toEqual({
input: '\u001b',
useMux: true,
clientId: 'codeman-agent-cli-058ee7b5-interrupt',
seq: 1_700_000_000_000,
});
const self = fakeDeps([]);
expect(await agentInterrupt(self, { id: SELF })).toBe(EXIT.refused);
expect(self.calls).toEqual([]);
});
});
describe('send takes the prompt as ONE argument', () => {
it('refuses several words, which an unquoted multi-line $(…) becomes after word splitting', () => {
expect(sendPromptFromArgs(['review src/, then say DONE'])).toEqual({ text: 'review src/, then say DONE' });
expect(sendPromptFromArgs(['line', 'one', 'line', 'two'])).toMatchObject({
error: expect.stringMatching(/ONE argument, got 4/),
});
// A leading "-" is commander's option syntax, so the hint names the escape.
expect(sendPromptFromArgs(['a', 'b'])).toMatchObject({ error: expect.stringContaining('send <id> -- "- fix') });
});
});
describe('agent spawn: a worker that dies during the readiness wait', () => {
it('is exit 3 with its own line, not the composer-timeout hint', async () => {
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { ended: true, matched: false } })]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.dead);
expect(deps.err.join('')).toMatch(/exited during the readiness wait/);
expect(deps.err.join('')).not.toMatch(/composer not seen/);
});
});
describe('agent wait: a timeout is an answer, not a failure', () => {
it('prints a neutral line and exits 2', async () => {
const deps = fakeDeps([ok({ wait: { timedOut: true, timeoutMs: 1000 } })]);
expect(await agentWait(deps, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 })).toBe(EXIT.timeout);
const line = deps.out.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
expect(line).toBe('timed out after 1000 ms (exit 2)');
expect(deps.err).toEqual([]);
});
});
describe('agent spawn', () => {
it('quick-starts with lineage and waits for the claude composer', async () => {
const deps = fakeDeps([
ok({ sessionId: OTHER, caseName: 'scratch-1', casePath: '/x' }),
ok({ wait: { matched: true } }),
]);
expect(await agentSpawn(deps, { caseName: 'scratch-1', mode: 'claude', ready: true, timeoutMs: 2000 })).toBe(
EXIT.ok
);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: '/api/v1/quick-start',
body: { caseName: 'scratch-1', mode: 'claude', parentSessionId: SELF },
});
expect(deps.calls[1]).toMatchObject({
path: `/api/v1/sessions/${OTHER}/wait-output`,
query: { match: 'shift+tab', from: 'buffer', timeout: 2000 },
});
expect(deps.out).toEqual([OTHER]); // stdout is the id ALONE, so `SID=$(…)` works; prose goes to stderr
expect(deps.err.join('')).toMatch(/spawned .*composer up/s);
});
it('labels the case as agent scratch on the spawn request, and on no other request', async () => {
// The label drives a recursive-delete affordance in the Add Case UI: it may only ride
// the request that can CREATE a case directory (quick-start), never anything else.
const spawn = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: true } })]);
await agentSpawn(spawn, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 });
expect(spawn.calls[0].headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-agent-cli' });
expect(spawn.calls[1].headers?.['X-Codeman-Agent-Origin']).toBeUndefined(); // the readiness wait
const everyOther = fakeDeps((o) =>
o.path === '/api/v1/sessions' ? ok([]) : ok({ wait: { signal: 'stop' }, text: '' })
);
await agentLs(everyOther);
await agentSend(everyOther, { id: OTHER, text: 'hi', enter: true });
await agentWait(everyOther, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 });
await agentRead(everyOther, { id: OTHER });
await agentInterrupt(everyOther, { id: OTHER });
await agentRm(everyOther, { id: OTHER });
expect(everyOther.calls.length).toBeGreaterThan(5);
for (const call of everyOther.calls) expect(call.headers?.['X-Codeman-Agent-Origin'], call.path).toBeUndefined();
expect(baseHeaders(ctx())).not.toHaveProperty('X-Codeman-Agent-Origin');
});
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
expect(composerReadyMark('claude')).toBe('shift+tab');
expect(composerReadyMark('deepseek')).toBe('❯');
expect(composerReadyMark('pi')).toBeUndefined();
expect(composerReadyMark('no-such-cli')).toBeUndefined();
});
it('a composer that never shows up is exit 2 and the session is left for inspection, not deleted', async () => {
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: false, timedOut: true } })]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.timeout);
expect(deps.calls.map((c) => c.method)).toEqual(['POST', 'GET']);
expect(deps.err.join('')).toMatch(/startup dialog/);
});
it('a mode without a readiness mark returns after the create, and --no-ready skips the wait everywhere', async () => {
const pi = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
expect(await agentSpawn(pi, { caseName: 'c', mode: 'pi', ready: true, timeoutMs: 1000 })).toBe(EXIT.ok);
expect(pi.calls).toHaveLength(1);
const noReady = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
expect(await agentSpawn(noReady, { caseName: 'c', mode: 'claude', ready: false, timeoutMs: 1000 })).toBe(EXIT.ok);
expect(noReady.calls).toHaveLength(1);
});
it('a failed readiness call reports its own reason instead of the trust-dialog hint', async () => {
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), apiError(429, 'RATE_LIMITED', 'waiter pool full')]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/readiness check failed: RATE_LIMITED/);
expect(deps.err.join('')).not.toMatch(/trust dialog/);
expect(deps.out).toEqual([OTHER]); // the session exists; the id is still handed back
});
it('a failed quick-start is terminal: the error code is shown and nothing else is called', async () => {
const deps = fakeDeps([apiError(409, 'SESSION_BUSY', 'session cap reached')]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.calls).toHaveLength(1);
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
});
});
describe('session id prefixes', () => {
const THIRD = '94990c6d-ffff-4000-8000-000000000000';
const list = ok([{ id: SELF }, { id: OTHER }, { id: THIRD }]);
it('a full id goes straight to the route, no list call', async () => {
const deps = fakeDeps([ok({ text: 'x' })]);
await agentRead(deps, { id: OTHER });
expect(deps.calls.map((c) => c.path)).toEqual([`/api/v1/sessions/${OTHER}/last-response`]);
});
it('a unique prefix (what `ls` prints) resolves through the list — the routes 404 on prefixes', async () => {
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ text: 'x' })]);
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.ok);
expect(deps.calls.map((c) => c.path)).toEqual(['/api/v1/sessions', `/api/v1/sessions/${OTHER}/last-response`]);
});
it('an ambiguous prefix refuses instead of picking one', async () => {
const deps = fakeDeps([list]);
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/ambiguous.*94990c6d-e461.*94990c6d-ffff/);
expect(deps.calls).toHaveLength(1);
});
it('an unknown prefix names the problem', async () => {
const deps = fakeDeps([list]);
expect(await agentRm(deps, { id: 'deadbeef' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/no session starts with "deadbeef"/);
expect(deps.calls.map((c) => c.method)).toEqual(['GET']);
});
it('a prefix shorter than 8 characters refuses (exit 4) before any request, on every verb', async () => {
expect(MIN_ID_PREFIX_LENGTH).toBe(8);
// The maintainer's repro: `rm 9` with one other session starting with 9 deleted it.
const rm = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({})]);
expect(await agentRm(rm, { id: '9' })).toBe(EXIT.refused);
expect(rm.calls).toEqual([]);
expect(rm.err.join('')).toMatch(/"9" is shorter than 8 characters.*8-character id `agent ls` prints/);
const short = OTHER.slice(0, 7);
const verbs: Array<[string, (deps: AgentDeps) => Promise<number>]> = [
['send', (d) => agentSend(d, { id: short, text: 'go', enter: true })],
['wait', (d) => agentWait(d, { id: short, until: 'idle', timeoutMs: 1000 })],
['read', (d) => agentRead(d, { id: short })],
['interrupt', (d) => agentInterrupt(d, { id: short })],
['rm', (d) => agentRm(d, { id: short })],
];
for (const [verb, call] of verbs) {
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ delivered: true })]);
expect(await call(deps), verb).toBe(EXIT.refused);
expect(deps.calls, verb).toEqual([]);
}
});
it('rm runs the self guard before the list and again on the resolved id', async () => {
const first = fakeDeps([ok([{ id: SELF }])]);
expect(await agentRm(first, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
expect(first.calls).toEqual([]); // refused before any request
const resolved = fakeDeps([ok([{ id: SELF }, { id: OTHER }])]);
expect(await agentRm(resolved, { id: 'deadbeef' })).toBe(EXIT.error); // nothing to delete
expect(resolved.calls.map((c) => c.method)).toEqual(['GET']);
});
});
describe('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
it('--match says the prompt must not contain the marker verbatim, and how to split it', () => {
const match = sub('wait').options.find((o) => o.long === '--match')!;
expect(match.description).toMatch(/never put the marker verbatim in the prompt/);
expect(match.description).toMatch(/WORKDONE followed by _4711.*WORKDONE_4711/);
});
it('send names the -- escape for a prompt that starts with "-"', () => {
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
});
it('rm does not claim a lineage check it does not make', () => {
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
});
});
describe('option parsing', () => {
it('positive integers only, the server rejects the rest', () => {
expect(parsePositiveInt(undefined, 60000)).toBe(60000);
expect(parsePositiveInt('1500', 1)).toBe(1500);
for (const bad of ['0', '-1', '1.5', '30s', '']) expect(() => parsePositiveInt(bad, 1)).toThrow(/positive integer/);
});
it('describeFailure prefers the envelope and falls back to the status line', () => {
expect(describeFailure(apiError(404, 'NOT_FOUND', 'no such session'))).toBe(
'NOT_FOUND: no such session (HTTP 404)'
);
expect(describeFailure({ status: 403, text: 'Forbidden: host not allowed' })).toBe(
'HTTP 403 Forbidden: host not allowed'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// The real transport against a local server
// ─────────────────────────────────────────────────────────────────────────────
describe('httpRequest', () => {
let server: http.Server;
let apiUrl: string;
const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() });
if (req.url?.startsWith('/plain')) {
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
return;
}
res
.writeHead(200, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: true, data: { echo: true } }));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address() as { port: number };
apiUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('carries lineage headers, Basic auth and a urlencoded query (the + in shift+tab survives)', async () => {
const res = await httpRequest(ctx({ apiUrl, auth: { username: 'joe', password: 'pw' } }), {
method: 'GET',
path: '/api/v1/sessions/x/wait-output',
query: { match: 'shift+tab', from: 'buffer', timeout: 1000, nocase: undefined },
});
expect(res.status).toBe(200);
expect(res.json).toEqual({ success: true, data: { echo: true } });
const last = seen.at(-1)!;
expect(last.url).toBe('/api/v1/sessions/x/wait-output?match=shift%2Btab&from=buffer&timeout=1000');
expect(last.headers['x-codeman-parent-session']).toBe(SELF);
expect(last.headers['x-codeman-agent-origin']).toBeUndefined(); // only spawn's quick-start carries it
expect(last.headers.authorization).toBe(`Basic ${Buffer.from('joe:pw').toString('base64')}`);
});
it('posts JSON bodies with a length, and no auth header when the server is open', async () => {
await httpRequest(ctx({ apiUrl }), {
method: 'POST',
path: '/api/v1/sessions/x/input',
body: { input: 'hi\r', seq: 1 },
});
const last = seen.at(-1)!;
expect(last.method).toBe('POST');
expect(JSON.parse(last.body)).toEqual({ input: 'hi\r', seq: 1 });
expect(last.headers['content-type']).toBe('application/json');
expect(last.headers.authorization).toBeUndefined();
});
it('keeps a plain-text body when the answer is not JSON', async () => {
const res = await httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/plain' });
expect(res.status).toBe(401);
expect(res.json).toBeUndefined();
expect(res.text).toBe('Unauthorized');
});
});
-2
View File
@@ -33,7 +33,6 @@ function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: C
const TOP_LEVEL: Record<string, string[]> = {
attach: [],
skill: [],
agent: [],
session: ['s'],
task: ['t'],
ralph: ['r'],
@@ -54,7 +53,6 @@ const SUBCOMMANDS: Record<string, Record<string, string[]>> = {
task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] },
ralph: { start: [], stop: [], status: [] },
skill: { install: [], uninstall: [] },
agent: { ls: ['list'], spawn: [], send: [], wait: [], read: [], interrupt: [], rm: [] },
service: { install: [], uninstall: [], status: [] },
users: { add: [], passwd: [], list: ['ls'], rm: [] },
};
-61
View File
@@ -1,61 +0,0 @@
/**
* @fileoverview The one credential reader `codeman attach`, `codeman tui` and
* `codeman agent` share: env first, the data dir's `.env` as the fallback.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { basicAuthHeader, readCodemanCredentials, readCodemanEnvFile } from '../src/codeman-credentials.js';
describe('readCodemanCredentials', () => {
let dir: string;
const saved = { user: process.env.CODEMAN_USERNAME, pass: process.env.CODEMAN_PASSWORD };
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-cred-'));
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_PASSWORD;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (saved.user === undefined) delete process.env.CODEMAN_USERNAME;
else process.env.CODEMAN_USERNAME = saved.user;
if (saved.pass === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = saved.pass;
});
it('falls back to the .env file, quotes and an export prefix stripped, default user admin', () => {
const env = join(dir, '.env');
writeFileSync(env, '# a comment\nnot an assignment\nexport CODEMAN_PASSWORD="hunter2"\n');
expect(readCodemanCredentials(env)).toEqual({ username: 'admin', password: 'hunter2' });
});
it('prefers the environment over the file', () => {
const env = join(dir, '.env');
writeFileSync(env, 'CODEMAN_USERNAME=file\nCODEMAN_PASSWORD=file-pass\n');
process.env.CODEMAN_USERNAME = 'envuser';
process.env.CODEMAN_PASSWORD = 'env-pass';
expect(readCodemanCredentials(env)).toEqual({ username: 'envuser', password: 'env-pass' });
});
it('an absent file means no password, and no header to send', () => {
const creds = readCodemanCredentials(join(dir, 'missing'));
expect(creds).toEqual({ username: 'admin' });
expect(basicAuthHeader(creds)).toBeUndefined();
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
});
it('takes an explicit environment, field by field', () => {
const env = join(dir, '.env');
writeFileSync(env, 'CODEMAN_USERNAME=joe\n');
expect(readCodemanCredentials(env, { CODEMAN_PASSWORD: 'pw' })).toEqual({ username: 'joe', password: 'pw' });
});
it('builds a Basic header from a password', () => {
expect(basicAuthHeader({ username: 'joe', password: 'pw' })).toBe(
`Basic ${Buffer.from('joe:pw').toString('base64')}`
);
});
});
+2 -2
View File
@@ -1342,8 +1342,8 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
expect(modal.classList.contains('active')).toBe(true);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
expect(message).toContain((16384).toLocaleString()); // the modal formats for the user's locale
expect(message).toContain((40000).toLocaleString());
expect(message).toContain('16,384');
expect(message).toContain('40,000');
expect(message).toMatch(/llama-swap/i);
expect(message).toMatch(/fit-ctx/i);
+6 -19
View File
@@ -6,7 +6,6 @@
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import {
buildBaseUrl,
buildStatusUrl,
@@ -17,17 +16,7 @@ import {
probeServer,
} from '../src/daemon-control.js';
/**
* A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
* moment of use, unlike "the server's port + 1", which anything may hold.
*/
async function closedPort(): Promise<number> {
const probe = http.createServer();
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
const { port: free } = probe.address() as AddressInfo;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return free;
}
const PORT = 3216;
describe('buildWebArgs', () => {
it('always passes host and port through explicitly', () => {
@@ -154,7 +143,6 @@ describe('isProcessAlive', () => {
describe('probeServer', () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
@@ -169,8 +157,7 @@ describe('probeServer', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
});
afterAll(async () => {
@@ -178,23 +165,23 @@ describe('probeServer', () => {
});
it('reports up and reads the version back', async () => {
const result = await probeServer(`http://127.0.0.1:${port}/api/status`);
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
expect(result.up).toBe(true);
expect(result.version).toBe('9.9.9');
});
it('counts a 401 as up, because auth being active proves a server is there', async () => {
const result = await probeServer(`http://127.0.0.1:${port}/unauthorized`);
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
expect(result.up).toBe(true);
});
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
const result = await probeServer(`http://127.0.0.1:${port}/foreign`);
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
expect(result.up).toBe(false);
});
it('reports down when nothing is listening', async () => {
const result = await probeServer(`http://127.0.0.1:${await closedPort()}/api/status`, 1000);
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
expect(result.up).toBe(false);
});
+4 -7
View File
@@ -16,7 +16,6 @@
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
import { dirname } from 'node:path';
import {
@@ -26,6 +25,8 @@ import {
DEEPSEEK_STATE_TO_HOOK_EVENT,
} from '../src/deepseek-status-shim.js';
const PORT = 3251;
describe('DeepSeek status shim: provisioning', () => {
beforeEach(() => {
resetDeepSeekStatusShimForTest();
@@ -72,7 +73,6 @@ describe('DeepSeek status shim: provisioning', () => {
describe('DeepSeek status shim: the supervisor contract', () => {
let server: Server | undefined;
let port: number;
const received: Array<{ body: unknown; secret: string | undefined }> = [];
let status = 200;
@@ -96,10 +96,7 @@ describe('DeepSeek status shim: the supervisor contract', () => {
res.end('{}');
});
});
server.listen(0, '127.0.0.1', () => {
port = (server!.address() as AddressInfo).port;
resolve();
});
server.listen(PORT, '127.0.0.1', resolve);
});
beforeAll(() => listen());
@@ -123,7 +120,7 @@ describe('DeepSeek status shim: the supervisor contract', () => {
const child = spawn(process.execPath, [path, ...args], {
env: {
...process.env,
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
CODEMAN_SESSION_ID: 'sess-from-env',
...env,
},
+4 -2
View File
@@ -3,6 +3,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3196;
const REPORT = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
@@ -44,13 +46,13 @@ describe('Diagnostics panel in a real browser', () => {
let page: Page;
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
// A controlling service worker can swallow requests before page.route() sees them, letting the
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
}, 90000);
+7 -6
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3110;
const CASES_DIR = join(homedir(), 'codeman-cases');
describe('Edge Cases and Error Handling', () => {
@@ -12,9 +13,9 @@ describe('Edge Cases and Error Handling', () => {
const createdCases: string[] = [];
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
baseUrl = `http://localhost:${TEST_PORT}`;
});
afterEach(() => {
@@ -262,9 +263,9 @@ describe('Concurrent Session Handling', () => {
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(TEST_PORT + 1, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
baseUrl = `http://localhost:${TEST_PORT + 1}`;
});
afterAll(async () => {
@@ -359,9 +360,9 @@ describe('API Request Validation', () => {
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(TEST_PORT + 2, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
baseUrl = `http://localhost:${TEST_PORT + 2}`;
});
afterAll(async () => {
+7 -123
View File
@@ -31,7 +31,6 @@ const SURFACES = [
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
] as const;
/**
@@ -41,11 +40,6 @@ const SURFACES = [
*/
const CSS_LESS_STYLES = new Set(['off', 'fly']);
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
function isCssLess(attr: string, key: string): boolean {
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
}
function styleKeys(arrayName: string): string[] {
const start = animSource.indexOf(`const ${arrayName} = [`);
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
@@ -53,21 +47,12 @@ function styleKeys(arrayName: string): string[] {
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
}
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
function themes(): Theme[] {
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
const start = animSource.indexOf('const ANIM_THEMES = [');
const body = animSource.slice(start, animSource.indexOf('];', start));
const parsed = [
...body.matchAll(
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
// Every theme entry must parse: a theme missing a surface (or a regex that
// drifted from the source) would otherwise pass the checks below vacuously.
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
expect(parsed.length).toBeGreaterThan(0);
return parsed;
return [
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
}
/**
@@ -97,7 +82,7 @@ describe('entrance animation styles', () => {
describe(`${surface.attr} surface`, () => {
it('backs every style with a rule that names a keyframe block that exists', () => {
for (const key of styleKeys(surface.array)) {
if (isCssLess(surface.attr, key)) {
if (CSS_LESS_STYLES.has(key)) {
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
continue;
}
@@ -114,16 +99,8 @@ describe('entrance animation styles', () => {
});
}
/**
* Tiles are the exception: six frames animate at once, so their styles stay
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
*/
it('ships the blur style on the four single-element surfaces', () => {
for (const surface of SURFACES) {
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
else expect(styleKeys(surface.array)).toContain('blur');
}
it('ships the blur style on all four surfaces', () => {
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
});
it('gives every theme an <option> and only styles that exist', () => {
@@ -196,96 +173,3 @@ describe('entrance animation styles', () => {
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
});
describe('tile grid entrance styles', () => {
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
const tileNames = (key: string, scope: RegExp) =>
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
.filter((rule) => scope.test(rule[1]))
.flatMap((rule) =>
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
name: m[1],
onPseudo: rule[1].includes('::before'),
}))
);
/**
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
* A tile fits once at its final size (#464); a box-model property here would
* resize its PTY mid-animation, and a filter on six live terminals at once
* is the frame-time cost the pane's `blur` takes for one.
*/
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
if (key === 'off') continue;
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
for (const { name, onPseudo } of names) {
const body = keyframeBody(name);
expect(body, `@keyframes ${name} missing`).not.toBeNull();
if (onPseudo) continue; // a wash over the tile, no layout of its own
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
}
}
}
});
/**
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
* animationend, and the still copy goes on its last tile's `tile-leave*`:
* a keyframe named otherwise would leave the class on (or the copy up)
* until a backstop timer.
*/
it('names every frame keyframe for the events tile-grid.js listens for', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
}
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
}
}
});
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
expect(timed.length).toBeGreaterThan(0);
for (const key of timed) {
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
}
expect(timed).not.toContain('settle');
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
// The legacy theme (the default) leaves the grid's own motion untouched.
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
});
/**
* App Settings → Appearance → Tile Animations: one option per style, wired
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
* own `settle` first and named as the off default.
*/
it('lists every tile style in the Tile Animations setting, off by default', () => {
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
expect(values[0]).toBe('settle');
expect(select).toContain('<option value="settle">Off (default)</option>');
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
// Nothing new for an install that never picks it: no saved key means settle.
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
});
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
it('plays every terminal pane style on a tile screen too', () => {
for (const key of styleKeys('TERM_ANIM_STYLES')) {
if (CSS_LESS_STYLES.has(key)) continue;
// The body itself, not only its ::before wash.
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
}
});
});
+4 -4
View File
@@ -4,7 +4,7 @@
* Tests that file paths displayed in terminal output are clickable
* and open the log viewer window correctly.
*
* Port allocation: ephemeral port
* Port allocation: 3154 (see CLAUDE.md test port table)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -14,7 +14,8 @@ import { writeFileSync, mkdirSync, rmSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
let baseUrl: string;
const TEST_PORT = 3154;
const baseUrl = `http://localhost:${TEST_PORT}`;
const BROWSER_TIMEOUT = 30000;
// Helper to run agent-browser commands
@@ -96,9 +97,8 @@ describe('File Link Click Tests', () => {
testLogFile = join(testDir, 'test.log');
writeFileSync(testLogFile, '=== Test Log Started ===\n');
server = new WebServer(0, false, true);
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
await new Promise((r) => setTimeout(r, 1000));
// Test if browser is available
-8
View File
@@ -239,14 +239,6 @@ describe('gitNonInteractiveEnv', () => {
it("does not override a user's own GIT_SSH_COMMAND", () => {
expect(gitNonInteractiveEnv({ GIT_SSH_COMMAND: 'ssh -F /custom' }).GIT_SSH_COMMAND).toBe('ssh -F /custom');
});
// Issue #568. CI runs in an English locale, so the real-git tests below cannot
// catch a revert: only this assertion fails if the pin is dropped.
it("pins git's messages to English over the host's locale, since classifyGitFailure() matches English stderr", () => {
const env = gitNonInteractiveEnv({ LC_ALL: 'de_DE.UTF-8', LANG: 'de_DE.UTF-8', LANGUAGE: 'de' });
expect(env.LC_ALL).toBe('C');
expect(env.LANG).toBe('C');
});
});
describe('parseLsRemoteOutput', () => {
+3 -2
View File
@@ -7,6 +7,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3192;
const ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
@@ -84,7 +85,7 @@ describe('Git status indicator in a real browser', () => {
write('a.txt', '2\n');
write('new file.txt', 'n\n');
server = new WebServer(0, false, true);
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
@@ -95,7 +96,7 @@ describe('Git status indicator in a real browser', () => {
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
repoSession = await createSession(repo);
plainSession = await createSession(plain);
+278
View File
@@ -0,0 +1,278 @@
/**
* @fileoverview The partial-history notice waits until the user reaches for history.
*
* Every tab switch after the first replays a 1 MiB TAIL of the session's byte
* stream, which is truncated for any session that has run for a while, so the
* notice ("Showing the most recent 1.0 MB of this session. 4.8 MB more may still
* be retained.") covered the top rows on nearly every switch. Its × only lasted
* until the next switch. Now:
* - it appears only once a scroll gesture reaches the top of the browser's
* buffer, after the history pull that gesture starts has settled;
* - a scroll back down to live output retires it, and so does a tab switch;
* - a dismissal sticks for that session until the page reloads.
*
* Runs the REAL methods (app.js banner + state, terminal-ui.js scroll hook,
* constants.js notice decision) in a `vm` against a stub DOM, the same way
* shell-scroll-history-pull.test.ts does (no jsdom on this box).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const APP = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
interface FakeEl {
tagName: string;
hidden: boolean;
className: string;
type: string;
disabled: boolean;
children: FakeEl[];
attrs: Record<string, string>;
onclick: null | (() => void);
textContent: string;
appendChild(child: FakeEl): void;
setAttribute(name: string, value: string): void;
}
function fakeEl(tagName: string): FakeEl {
let text = '';
const el: FakeEl = {
tagName,
hidden: false,
className: '',
type: '',
disabled: false,
children: [],
attrs: {},
onclick: null,
get textContent() {
return text + el.children.map((c) => c.textContent).join('');
},
set textContent(value: string) {
text = value;
el.children = [];
},
appendChild(child) {
el.children.push(child);
},
setAttribute(name, value) {
el.attrs[name] = value;
},
};
return el;
}
/** Real terminal-ui.js mixin, for `_maybeLoadMoreHistoryOnScroll` / `isTerminalAtBottom`. */
function loadTerminalMixin(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function makeApp() {
const bar = fakeEl('div');
bar.hidden = true;
const document = {
getElementById: (id: string) => (id === 'historyTruncationBar' ? bar : null),
createElement: (tag: string) => fakeEl(tag),
};
const methods = [
'_setHistoryTruncation',
'_clearHistoryTruncation',
'_setHistoryNoticeRevealed',
'_renderHistoryTruncationBanner',
]
.map((m) => methodSource(APP, m))
.join(',\n');
const context = vm.createContext({ document, console, window: {}, navigator: { userAgent: 'test' } });
const appMethods = vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;({ ${methods} })`,
context,
{ filename: 'app-methods.js' }
) as Record<string, (...args: unknown[]) => unknown>;
const mixin = loadTerminalMixin();
const buffer = { viewportY: 500, baseY: 500 };
let resolvePull: (() => void) | null = null;
const app = {
activeSessionId: 's1' as string | null,
terminal: { buffer: { active: buffer } },
// Each gesture's pull is held open until the test settles it.
_maybeRefetchFullHistory: vi.fn(
() =>
new Promise<void>((res) => {
resolvePull = res;
})
),
...appMethods,
_maybeLoadMoreHistoryOnScroll: mixin._maybeLoadMoreHistoryOnScroll,
isTerminalAtBottom: mixin.isTerminalAtBottom,
} as Record<string, any>;
const settle = async () => {
resolvePull?.();
resolvePull = null;
for (let i = 0; i < 5; i++) await Promise.resolve();
};
const scrollTo = async (viewportY: number) => {
const lines = viewportY - buffer.viewportY;
buffer.viewportY = viewportY;
app._maybeLoadMoreHistoryOnScroll(lines);
await settle();
};
return { app, bar, buffer, scrollTo, settle };
}
// What a tab switch's tail replay reports for a session with real scrollback.
const TAIL = {
truncated: true,
truncationReason: 'tail',
source: 'mux-visible',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
paneHistoryLines: 40000,
};
const loadButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-load');
const dismissButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-dismiss');
describe('partial-history notice: lazy reveal', () => {
it('stays hidden after a truncated tab-switch replay', () => {
const { app, bar } = makeApp();
app._setHistoryTruncation('s1', TAIL);
expect(bar.hidden).toBe(true);
});
it('appears once a scroll reaches the top, after the pull that gesture started', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1);
// Still pulling: no notice describing the state the pull is about to replace.
expect(bar.hidden).toBe(true);
await settle();
expect(bar.hidden).toBe(false);
expect(bar.textContent).toContain('40,000 lines of scrollback are retained.');
expect(loadButton(bar)?.textContent).toBe('Load full history');
});
it('shows what the pull left: nothing, when the pull brought everything back', async () => {
const { app, bar, buffer } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._maybeRefetchFullHistory.mockImplementation(async () => {
app._setHistoryTruncation('s1', { truncated: false, source: 'mux-full-history', paneHistoryLines: 40000 });
});
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('does not appear on the way up, only at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(200);
expect(app._maybeRefetchFullHistory).not.toHaveBeenCalled();
expect(bar.hidden).toBe(true);
});
it('goes away once the user scrolls back down to live output', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(false);
await scrollTo(300); // still reading history
expect(bar.hidden).toBe(false);
await scrollTo(500); // back at the bottom
expect(bar.hidden).toBe(true);
});
it('never appears for a pane with no scrollback (fullscreen CLI), even at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', { ...TAIL, paneHistoryLines: 0 });
await scrollTo(0);
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('is not revealed for a tab the user switched to while the pull ran', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._setHistoryTruncation('s2', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
app.activeSessionId = 's2';
await settle();
expect(app._historyNoticeRevealedFor ?? null).toBe(null);
expect(bar.hidden).toBe(true);
});
it('keeps a dismissal for that session, but only that session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
expect(bar.hidden).toBe(true);
// A new replay and another trip to the top do not bring it back.
await scrollTo(500);
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(true);
// Another session still gets its notice.
app.activeSessionId = 's2';
app._setHistoryTruncation('s2', TAIL);
await scrollTo(500);
await scrollTo(0);
expect(bar.hidden).toBe(false);
});
it('forgets the dismissal with the session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
app._clearHistoryTruncation('s1');
expect(app._historyNoticeDismissed.has('s1')).toBe(false);
});
});
describe('partial-history notice: a tab switch retires it (static guard)', () => {
it('selectSession clears the reveal before repainting the banner', () => {
const body = methodSource(APP, 'selectSession');
const reset = body.indexOf('this._historyNoticeRevealedFor = null;');
const render = body.indexOf('this._renderHistoryTruncationBanner();');
expect(reset).toBeGreaterThan(-1);
expect(render).toBeGreaterThan(reset);
});
});
+64
View File
@@ -45,6 +45,11 @@ describe('formatHistoryBytes', () => {
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => {
expect(formatHistoryBytes(1048351)).toBe('1.0 MB');
expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB');
});
it('survives junk input rather than printing NaN into the UI', () => {
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
@@ -114,6 +119,65 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
});
});
describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
// The tab-switch tail of a fullscreen claude pane, as measured on prod: the
// server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows.
const fullscreenTail = {
truncated: true,
reason: 'tail',
source: 'mux-visible',
fullSize: 6158853,
retainedBytes: 1048351,
};
it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => {
// The dropped bytes were old repaints of one frame, and `full=1` returns
// only the visible frame for such a pane, so the button could only ever end
// in the downgrade refusal. That is the banner that showed on every switch.
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 });
expect(notice).toEqual({ visible: false, message: '', canLoadMore: false });
});
it('stays silent for such a pane in the exhausted and at-ceiling states too', () => {
expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe(
false
);
expect(
computeHistoryTruncationNotice({
...fullscreenTail,
source: 'mux-full-history',
reason: 'capped',
paneHistoryLines: 0,
}).visible
).toBe(false);
});
it('names the scrollback lines a pull can load instead of the byte gap', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toBe(
'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.'
);
expect(notice.message).not.toContain('4.9 MB');
});
it('uses the singular for one line', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 });
expect(notice.message).toContain('1 line of scrollback is retained.');
});
it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => {
for (const paneHistoryLines of [undefined, null, NaN]) {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('more may still be retained');
}
});
});
describe('the in-terminal truncation line is gone (static guard)', () => {
it('no longer writes the notice into terminal output', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
+7 -5
View File
@@ -782,19 +782,21 @@ describe('subagent stop guard helper', () => {
});
// ========== Hook Event API Integration Tests ==========
// Hooks integration tests use an ephemeral port
// Port 3130 reserved for hooks integration tests
import { WebServer } from '../src/web/server.js';
const TEST_PORT = 3130;
describe('Hook Event API', () => {
let server: WebServer;
let baseUrl: string;
let testSessionId: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
baseUrl = `http://localhost:${TEST_PORT}`;
// Create a test session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
@@ -974,9 +976,9 @@ describe('Hook Data Sanitization', () => {
let testSessionId: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(TEST_PORT + 1, false, true); // Port 3131
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
baseUrl = `http://localhost:${TEST_PORT + 1}`;
// Create a test session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
+4 -3
View File
@@ -11,14 +11,15 @@ import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
const PORT = 3168;
describe('Stable HTTP contract (live server)', () => {
let server: WebServer;
let base: string;
const base = `http://localhost:${PORT}`;
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(PORT, false, true);
await server.start();
base = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
-12
View File
@@ -238,16 +238,4 @@ describe('image upload insertion policy', () => {
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
});
it('shows the server reason in the toast when an upload fails', async () => {
const app = loadImageInputApp();
const reason = 'Prompt uploads are not supported for remote (SSH) sessions';
app._uploadPasteImage = vi.fn(async () => {
throw new Error(reason);
});
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
expect(app.showToast).toHaveBeenCalledWith(`1 failed: ${reason}`, 'error');
});
});
-9
View File
@@ -31,7 +31,6 @@ vi.mock('node:fs', async (importOriginal) => {
});
import { ImageWatcher } from '../src/image-watcher.js';
import { watch } from 'chokidar';
import { statSync } from 'node:fs';
describe('ImageWatcher', () => {
@@ -93,14 +92,6 @@ describe('ImageWatcher', () => {
expect(watcher.getWatchedSessions()).toHaveLength(1);
});
it("ignores Codeman's own upload folders, so a pdf the user handed over is not a detected artifact", () => {
watcher.watchSession('session-1', '/home/user/project');
const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }];
expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true);
expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true);
expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false);
});
it('should replace watcher when working directory changes', () => {
watcher.watchSession('session-1', '/home/user/project-a');
watcher.watchSession('session-1', '/home/user/project-b');
+14 -10
View File
@@ -13,14 +13,18 @@
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
*
* Ports: ephemeral, for this and the two server-backed describes below
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
* (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
let baseUrl: string;
const PORT = 3164;
const ORDERING_PORT = 3165;
const LONG_PREFIX_PORT = 3192;
const BASE_URL = `http://localhost:${PORT}`;
describe('Inline rename input', () => {
let server: WebServer;
@@ -28,12 +32,11 @@ describe('Inline rename input', () => {
let page: Page;
beforeAll(async () => {
server = new WebServer(0, false, true); // testMode = true
server = new WebServer(PORT, false, true); // testMode = true
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Wait for app.js to expose window.app and finish constructor init.
await page.waitForFunction(
() =>
@@ -664,11 +667,11 @@ describe('Inline rename write ordering', () => {
type Pending = { body: string; resolve: (response: Response) => void };
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(ORDERING_PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
typeof (window as { app?: unknown }).app !== 'undefined' &&
@@ -1007,13 +1010,14 @@ describe('Inline rename write ordering', () => {
describe('Vertical rail rename editor with a long prefix', () => {
let server: WebServer;
let browser: Browser;
const port = LONG_PREFIX_PORT;
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
let sessionId = '';
beforeAll(async () => {
server = new WebServer(0, false, true);
server = new WebServer(port, false, true);
await server.start();
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
const res = await fetch(`http://localhost:${port}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: NAME, mode: 'shell' }),
@@ -1044,7 +1048,7 @@ describe('Vertical rail rename editor with a long prefix', () => {
settings
);
const page = await context.newPage();
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
await row.waitFor({ state: 'visible', timeout: 15000 });

Some files were not shown because too many files have changed in this diff Show More