Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c85007ebb |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
|
||||
@@ -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.
|
||||
@@ -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.32.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -28,15 +28,12 @@ The frontend is plain JS served from `src/web/public/` with no bundler in dev: e
|
||||
CI runs all of these, so save yourself a round trip:
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
```
|
||||
|
||||
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
@@ -55,9 +52,7 @@ npm run test:all # literally everything, environmental failures included
|
||||
|
||||
Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart.
|
||||
|
||||
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, pick a unique one at 3150 or above (search the repo for `const PORT =` first). 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,50 +0,0 @@
|
||||
name: Browser suite
|
||||
|
||||
# The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts),
|
||||
# so a browser-only regression can merge green. This job runs that suite on a schedule and on
|
||||
# demand, so such a regression (the Shift+Enter keypress bug was one) is caught within a day
|
||||
# instead of by a user. It is NOT a merge gate: a red run means "look", and it never blocks a
|
||||
# push or a PR.
|
||||
#
|
||||
# Needs: chromium (installed below), tmux, and the live server the tests start themselves.
|
||||
# Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and
|
||||
# codex-predictive-echo (needs a real codex binary; it also skips itself without one).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '29 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
browser:
|
||||
name: Playwright browser suite
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install tmux
|
||||
run: |
|
||||
if ! command -v tmux >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y tmux
|
||||
fi
|
||||
|
||||
- name: Install chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run the browser suite
|
||||
run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts
|
||||
@@ -34,13 +34,6 @@ jobs:
|
||||
- name: Frontend JS syntax check
|
||||
run: npm run check:frontend-syntax
|
||||
|
||||
# Asks `vitest list` what CI would actually collect, rather than matching
|
||||
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
|
||||
# (config/test-suites.ts) passes locally and dies in the test job with
|
||||
# "browserType.launch: Executable doesn't exist".
|
||||
- name: Browser-test exclusion check
|
||||
run: npm run check:browser-excludes
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
@@ -219,8 +212,6 @@ jobs:
|
||||
run: npx vitest run
|
||||
working-directory: packages/xterm-zerolag-input
|
||||
|
||||
# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml;
|
||||
# that job is informational and never gates a push or a PR.
|
||||
# Note: three suites are excluded from CI, each with its own local runner:
|
||||
# npm run test:browser Playwright + chromium (+ a live server, and a real
|
||||
# codex binary for codex-predictive-echo)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ src/web/public/settings-ui.js
|
||||
src/web/public/sw.js
|
||||
src/web/public/terminal-ui.js
|
||||
src/web/public/voice-input.js
|
||||
src/web/public/upload.html
|
||||
scripts/remotion/
|
||||
|
||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||
|
||||
@@ -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 ports must pick a unique `const PORT =`
|
||||
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
|
||||
- Never commit secrets or local state from `~/.codeman/`
|
||||
|
||||
@@ -1,255 +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.
|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||
- ### Thanks
|
||||
- @opticon454 for four PRs in this release: the end of Android autocorrect duplicating a line (#541), a Run dropdown that fits and scrolls on any screen (#542), Git status that copes with big folders and slow shares (#543), and a nightly browser-suite workflow plus the three stale browser tests it needed (#540). Thanks also for confirming #550 on a live install.
|
||||
- @JDProfresh, a first-time contributor, for four PRs: Respawn and Ralph back in Claude's Session Options (#550), removing the broken tunnel Upload URL page (#551), the wiki's Contributing page catching up with CONTRIBUTING.md (#552), and a remote-wake test that no longer pins its budget to the millisecond (#559).
|
||||
- @Randalix for making opencode tabs selectable and scrollable again (#555), and for the server reporting the port it really bound, which let the test suite move off fixed ports (#556).
|
||||
- @shenlvkang-collab for Default Codex model and reasoning effort settings (#546), and for the opt-in `window.CodemanHost` bridge that lets a native wrapper app pop a session or a file preview out into a window of its own (#432).
|
||||
- @aakhter for `.xlsx` spreadsheets in the File Viewer (#502), parsed entirely in the browser behind admission limits that held up to round after round of review.
|
||||
|
||||
This is the biggest visual overhaul Codeman has had: a tile grid for driving several sessions at once, a new header, CLI logos everywhere an agent is named, lineage trees, a calmer welcome screen and optional new tab layouts. The defaults are chosen so an upgrade looks familiar: the tab strip stays Classic, the header gets the Compact stats, and the Tiles button is there to try.
|
||||
|
||||
**The tile grid: up to six live sessions side by side.** Click **Tiles** in the header (or press `Ctrl+Shift+G`) and the window splits into a grid of real terminals, each one a full session you can read and type in, with its own connection. Every tile has a small header naming the session, the agent (its logo) and the model it runs (`Claude on Opus 5.5`, `DeepSeek on qwen3.8-27b`, following an in-session `/model` switch), a status dot that turns red and pulses the tile's border when that session needs you, a session menu, zoom (`⤢` or `Alt+Shift+Enter`) and remove (`×`, the session keeps running). Right-click the button for a 2 / 4 / 6 count menu (remembered per device; a hover card explains both clicks). Add sessions by `Ctrl`/`Cmd`+clicking a tab, dragging a tab onto a tile or an empty cell, "Open group as tiles" in a tab group's menu, or just Run while the grid is open. Rearrange by dragging a tile by its header (onto another tile they swap, and an empty cell can sit anywhere), with `Ctrl+Shift+Arrows`, and resize with the column and row dividers; `Alt+Shift+Arrows`, `Ctrl+Tab` and `Alt+[` / `]` move the focus, and the focused tile is the session every panel follows (files, git status, respawn, subagent windows, voice, image paste). Picking a tab that is not tiled shows it on its own and one click brings the grid back; app-driven selections never collapse it. The grid survives a reload (per device, session ids only), opens and closes with a short animation (off under reduced motion), auto-zooms the focused tile when the window gets too small, shows an Attach overlay for a session that is not attached or whose agent exited, and is fully translated into 简体中文. It was built to stay fast with six busy agents: tiles paint first and load their terminals one per frame, a tile replays its history at xterm's own pace and never reads more scrollback than it keeps, the main terminal is parked (no SSE terminal stream) while tiles own the screen, and a window resize refits each tile exactly once. Desktop only (a window at least 1180px wide); the setting is App Settings → Header & Panels → Tiles, on by default on desktop and opt-in on touch tablets. The user guide is the new [Tile Grid](https://github.com/Ark0N/Codeman/wiki/Tile-Grid) wiki page.
|
||||
|
||||
**Split view, rebuilt on the same terminal (#560).** The split's second pane is now a `TerminalTile`, the same component every tile is: its input goes through the exactly-once queue, so a dropped link can no longer lose or double a keystroke; it reconnects on its own after a drop or a server restart; it and its PTY never disagree about size; file paths are clickable and `Ctrl+V` pastes images into it; and app shortcuts, voice and image paste follow the pane you are in. Both panes now name their agent and model above them.
|
||||
|
||||
**A new header.** The header stats come in three styles (App Settings → Header & Panels → Header Stats Style, per device): **Compact**, the new default, draws WS, CPU, MEM and the plan usage windows as two pills where every reading is a ring, a label and a value; **Tiles** gives each one its own box; **As before** keeps the old readout. The icon buttons beside them take the matching shape, and hover now moves the icon, never the button.
|
||||
|
||||
**CLI logos everywhere an agent is named (#532).** The Run menus (toolbar, phone overview picker, custom endpoints, model picker), every agent tab (header strip, rail, sidebar, phone chips, the desktop home rail, Claude's tabs included), the tile and split headers and the welcome screen now show each CLI's own logo instead of a colour dot or a two-letter pill. The marks are inline SVG, follow every skin, and a CLI you added through `clis.json` gets a plain dot.
|
||||
|
||||
**Lineage trees (#544).** The lines from a tab to the tabs it spawned are now one rounded tree per spawning tab, routed through the gaps between tab rows so they never cross a tab or reach the terminal. Every family is always drawn, the selected tab's family is drawn thicker and on top, and a dashed branch now means that child is working.
|
||||
|
||||
**Tab layouts (#538, optional).** App Settings → Appearance → Tabs → **Tab Layout** adds three opt-in arrangements next to the default **Classic** strip: **By state** (a row each for Needs you, Waiting, Working and the rest, most urgent first, flippable with State Order), **By case** (one labelled box per case) and **Ledger** (an aligned grid of equal cells). By state and By case also group the vertical rail and the sidebar. Per device; phones keep their scrolling chip row.
|
||||
|
||||
**A calmer welcome screen.** One primary launcher for the first agent in your catalog (Claude Code on a stock install, the next agent if it is disabled) with its real logo, every other CLI as a slim pill under it, and Cloudflare Tunnel as a quiet link above its QR. The toolbar's "+" and case gear moved into the case picker as "New or link a case…" and "Case settings…" rows, and the duplicate instance stepper is gone (#428).
|
||||
|
||||
**Every session knows its model.** Sessions publish the model they run (`displayModel` on the session state): a custom endpoint's model id, else what the running CLI itself reports (Claude's statusLine, codex's, pi's and opencode's footers, the dsh status line), else the model its config pins (a DeepSeek route) or the one it was launched with. It is persisted, follows `/model` switches, and is stripped of control characters and capped.
|
||||
|
||||
**Idle detection for every agent.** OpenCode, Gemini, Pi and OMP turns now end: their composer bars and spinners are read so a session goes idle when the agent is done instead of spinning "working" forever, codex 0.162's new footer row no longer hides its model or its background-terminal row, and a freshly started or re-attached agent pane now announces its idle to open pages (it used to stay `busy` until a reload). A pane prompted within its first seconds is never settled idle at launch, so send-and-wait cannot resolve before the turn starts.
|
||||
|
||||
**Spreadsheets in the File Viewer (#502).** `.xlsx` files open as a read-only grid with sheet tabs, number formats, merged cells and colours, from the Files panel, attachments or a path an agent prints. They are parsed entirely in your browser in a worker (up to 10 MB) behind admission limits on everything the parser would expand. `.xls` and `.ods` stay download-only, and `file-content` now reports `type: 'spreadsheet'` for `.xlsx`.
|
||||
|
||||
**Default Codex model and reasoning effort (#546).** App Settings has a Default Codex model and a Default Codex reasoning effort, applied to new local Codex sessions (Run menu, Resume and the HTTP API) unless the launch names its own; custom endpoints, Docker and remote sessions keep their own settings.
|
||||
|
||||
**Pop-out windows for native wrapper apps (#432).** A native wrapper (for example an Android app on a foldable) can pop a session, a file preview or a web tab out into a window of its own beside the dashboard through an opt-in, experimental `window.CodemanHost` bridge. Browsers behave exactly as before.
|
||||
|
||||
**Git status for big folders and slow shares (#543).** Two per-device settings under Settings → Bottom bar set how many repositories it lists (up to 50, default 12) and how long one git command may take (5 to 120 s, now 30 s by default), and a repository git cannot read is listed with the reason and counted as `? N` in the indicator instead of silently disappearing.
|
||||
|
||||
**Behaviour change: `Ctrl+W` no longer closes a session.** It is delete-word in every shell and agent CLI, and muscle memory used to kill a session (its pane and CLI, with no confirm) mid-sentence. Close Session has no default key now; bind one in App Settings → Shortcuts if you want it.
|
||||
|
||||
**Removed: the tunnel Upload URL page (#551).** Since the response envelope change it reported "Saved: undefined" and listed nothing. Use the in-app image paste instead. `POST /api/screenshots`, `GET /api/screenshots` and `GET /api/screenshots/:name` (and their `/api/v1` aliases) still work unchanged but log a one-time deprecation warning and will be removed in a future major release.
|
||||
|
||||
**Fixes.** Android keyboards that autocorrect as you type (SwiftKey, Gboard) no longer duplicate the line in the prompt: the corrected word reaches the session exactly once, including when Enter arrives in the same keyboard transaction (#541). opencode tabs: a drag selects text again (so copy-on-select works and `Ctrl+C` copies instead of closing opencode), and the wheel and touch swipes page through opencode's conversation (#555). The Run dropdown no longer runs off the top of the screen: with many CLIs, endpoints and saved URLs it fits between the header and the toolbar, follows the on-screen keyboard and the iPhone safe areas, and scrolls (#542). Claude sessions show the Respawn and Ralph / Todo tabs and the auto-resume toggle in Session Options again, hidden since 1.33.0 (#550, fixes #549). The terminal refits when only its box changes size (a header that grows with the state rows or the lineage gutter used to leave the bottom rows clipped behind the toolbar until a tab switch). A device with any shortcut override no longer gets every App Settings save rejected while the toast still said "Settings saved". Image paste and dictation land in the session they started in. App Settings search finds Split and Tiles by what they do. The Run button family, the Help modal, the shortcut overlay leftovers and the exited-agent tab badge are translated into Chinese.
|
||||
|
||||
**For contributors.** The server reports the port it actually bound (`boundPort`), the tests that shared fixed ports bind ephemeral ones, and a static guard keeps new fixed ports out, so two test runs on one machine no longer collide (#556). A nightly (and on-demand) Playwright browser-suite workflow runs the suites the CI gate cannot, informational and never a gate (#540). The wiki's Contributing page says `npm test` is the CI gate (#552).
|
||||
|
||||
**A final review before shipping.** A last adversarially verified review of the whole release fixed these in the tile view: an image dropped on a tile uploads to that tile's session instead of navigating the browser away; a Claude session started into the grid keeps its welcome banner and transcript; a tile refresh never blanks the screen while it waits; a remote close or a reconnect no longer moves your keyboard into another session's tile; dictation and the phone keyboard's Path and Clear keys reach the focused tile or split pane; each tile caps its live-output backlog and recovers dropped output with one bounded refresh; Redraw on a tile forces the resize it reports; popping out the last tile leaves no frozen view; and "Open group as tiles" no longer pulls an open split into the grid. Also from that review: the By case layout stays one scrolling row on 600 to 767px tablets, the needs-you pulse animates opacity only, a codex session on the ultra effort shows its model, codex's launch defaults are CLI registry data instead of an id check, `npm run build` checks its dependencies before it deletes anything, and the release's new strings (case picker rows, Git status settings, toasts, tile and spreadsheet texts, the Redraw toasts) have zh-CN translations.
|
||||
|
||||
**Fixes applied while landing.** Tiles and the split's Pane B got the same two fixes the main terminal got from contributors: opencode's wheel paging and click reports (#555), and the Android keyboard handling that stops autocorrect duplicating a line (#541). Android: a word composed right before Enter in the same keyboard transaction was sent twice; it now arrives once (#541). Codex: App Settings refuses a Default Codex model the server would reject instead of failing the whole save behind a "Settings saved" toast, and the two Codex rows stack under their labels on phones (#546). Run dropdown: its height follows the on-screen keyboard and both iPhone safe areas, a long custom-endpoint label no longer adds a horizontal scrollbar, and the open menu sits above the keyboard accessory bar (#542). Git status: a lone repository git cannot read is no longer shown as clean and empty, a repeated `timeout` query parameter no longer answers 500, and the timeout field accepts any whole number of seconds (#543). Spreadsheets: cells clipped to nothing at the edge of a very large sheet no longer grow its scroll area, and the preview's fixed texts have zh-CN translations (#502). Pop-out windows: the bridge refuses to pop out without a window channel (the tab could never re-dock), the tab menu follows the host-aware default, and Close window works inside a host window (#432). Every deprecated `/api/screenshots` route now logs its warning, pinned per route (#551). Across the new tab layouts and header styles: lineage lines run between the state labels and the tabs and never through a case box, an open Tiles or Split button stays highlighted in the boxed header styles, a phone keeps the active chip in view when it changes state band, and the Tab Layout and Header Stats Style settings are translated.
|
||||
|
||||
## 1.35.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6f88e40: ### Thanks
|
||||
- @opticon454 for four PRs in one night: the Git status indicator with its panel of uncommitted and unpushed work and per-file diffs (#537), `codeman doctor` in Settings (#536), creating a case in a custom folder (#535) and the detailed-rail rename clamp fix (#534). Both review rounds came back within half an hour with every item addressed.
|
||||
- @aakhter for making the grouped vertical rail editable end to end (#525), the inline rename write queue and the long-prefix editor layout (#526), and bounded path probes so an unreachable network mount can no longer freeze the server (#516). Every round came back with tests that replay the exact sequences from the review, and the review nits were already fixed before landing.
|
||||
|
||||
**Edit tab groups in the vertical rail (#525).** The grouped rail is now editable from the browser: create, rename, reorder and delete groups, and move tabs between groups or back to Ungrouped, from the row menu, a group menu (Shift+F10, ContextMenu, right-click, or the header glyph, which stays visible on touch screens; F2 renames inline) or a mouse/pen drag. A flat rail offers "Move to new group" to make the first one. Every edit is a named operation saved through the existing `PUT /api/tab-layout`, one write in flight at a time; a version conflict replays the pending operations onto the server's layout and retries, so a concurrent edit from another device survives, and unsaved edits survive a reload. A drag released outside the rail leaves nothing behind, and committing a group rename by clicking elsewhere leaves focus where you clicked. No server changes.
|
||||
|
||||
**Inline rename that keeps up (#526).** Inline renames go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied even if the editor was reopened or cancelled meanwhile, an editor reopened over a rename in flight starts from that name, and a failed rename always shows its toast. A long `w<n>-<case>` prefix no longer pushes the editor out of its row in the rail or the sidebar, and the detailed rail no longer keeps its 3-line clamp around the editor (#534 found and fixed the same clamp independently).
|
||||
|
||||
**Git status in the bottom bar (#537).** Turn on Settings → Header & Panels → Bottom bar → "Git status" (per-device, off by default) and a small indicator shows the active session's repository at a glance (`● 3` uncommitted files, `↑ 2` commits not pushed, `✓` when everything is committed and pushed). Click it for a draggable window listing the uncommitted files (staged, not staged, untracked, conflicts; click one for its diff; grouped under collapsible folders) and the unpushed commits. A folder holding several projects gets a section per repository found up to two levels down, and an unrelated repository above the workspace (a dotfiles repo in your home folder) is ignored. Read-only and offline: Codeman never fetches or changes the repository, and git never runs on a repository a Docker case can write to. Not shown for Docker or remote sessions. New `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`.
|
||||
|
||||
**Diagnostics in Settings (#536).** Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, with versions, paths and install hints. The probe runs in a child process, so a slow `--version` cannot freeze the server, and both the panel and the terminal `codeman doctor` now also look in each CLI's usual install directories, so a CLI installed outside a service's minimal PATH is found. Admin only in multi-user mode.
|
||||
|
||||
**Create a case in a custom folder (#535).** Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case and listed alongside the rest (deleting it unlinks, never removes files). `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty, and system folders, the home folder, credential folders and the cases directory itself are refused. Nothing is left behind if creation fails part-way. Admin only in multi-user mode.
|
||||
|
||||
**An unreachable mount no longer freezes the server (#516).** A linked case can live on a network mount, and when that mount goes away a hard mount makes `stat()` wait indefinitely; the synchronous probes in the case routes, the workspace hook and statusLine helpers and session creation used to freeze the whole server with it. Those probes now go through one bounded, tri-state probe (present, absent, or unknown when nothing answers in time): a stalled path costs one threadpool worker, paths on the same mount answer "unknown" without a new stat, and unrelated paths keep working. "Unknown" is never treated as "absent": `GET /api/cases/:name` reports an unreachable linked case with `unreachable: true` instead of NOT_FOUND, Run creates a case only on a real NOT_FOUND, and session creation answers OPERATION_FAILED for a folder that did not answer and never scaffolds over it. Tunable with `CODEMAN_PATH_PROBE_TIMEOUT_MS` (default 1500) and `CODEMAN_PATH_PROBE_MAX_STALLED`.
|
||||
|
||||
**Fixes applied while landing.** Tab groups: an edit made while an earlier save was still in flight, and made inapplicable by that save's conflict (its group deleted on another device), is no longer dropped silently but reported like every other dropped edit, and the menus stop offering a new group once the 32-group limit is reached instead of failing with an untranslated error. Rail: a static CI check now pins that no rail or sidebar clamp out-ranks the rename unclamp (the browser test that caught it is outside the gate). Git status: a cached repository list is re-checked against the current Docker workspaces on every poll, a diff larger than 8 MB is cut short instead of failing, a diff click refreshes only that repository, a dotfiles repository above the workspace is identified with one `rev-parse` before any full status (a failing status there no longer hides the repositories below), and "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed. Doctor: candidates are judged like the Run menu's own resolver (a wrong binary on the PATH no longer hides the right one in an install directory, a non-executable file or a relative directory reads as missing), probes are killed with SIGKILL on timeout, a missing optional tool shows ○ instead of ✗, and the contract test no longer runs the machine's installed CLIs. Custom-folder cases: the symlink-resolved target is judged against resolved roots too (home reached through a link, macOS `/private/etc`), a target inside the cases directory is refused, the success toast names the folder the server created, the new labels have zh-CN translations, and the route test can no longer delete a real `~/projects` or the live linked-cases registry when run outside `npm test`.
|
||||
|
||||
## 1.34.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6aecc3b: ### Thanks
|
||||
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||
|
||||
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||
|
||||
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||
|
||||
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||
|
||||
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||
|
||||
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||
|
||||
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||
|
||||
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||
|
||||
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||
|
||||
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||
|
||||
## 1.33.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f776ad8: ### Thanks
|
||||
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
|
||||
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
|
||||
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
|
||||
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
|
||||
|
||||
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
|
||||
|
||||
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
|
||||
|
||||
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
|
||||
|
||||
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
|
||||
|
||||
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
|
||||
|
||||
## 1.33.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e439cf0: ### Thanks
|
||||
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
|
||||
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
|
||||
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
|
||||
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
|
||||
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
|
||||
|
||||
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
|
||||
|
||||
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
|
||||
|
||||
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
|
||||
|
||||
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
|
||||
|
||||
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
|
||||
|
||||
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
|
||||
|
||||
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
|
||||
|
||||
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
|
||||
|
||||
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
|
||||
|
||||
**Contributor tooling (#500).** `npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
|
||||
|
||||
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
|
||||
|
||||
## 1.33.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ### Thanks
|
||||
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
|
||||
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
|
||||
|
||||
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
||||
|
||||
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
|
||||
|
||||
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
|
||||
|
||||
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
|
||||
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
|
||||
|
||||
Thanks @opticon454.
|
||||
|
||||
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
|
||||
|
||||
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
|
||||
|
||||
Thanks @opticon454 (#487, #485).
|
||||
|
||||
## 1.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- CLI management from Settings (#476, finishing the CLI registry work from #343). `~/.codeman/clis.json` used to be hand-edit only; with the new opt-in `cliManagementEnabled` switch (synced, default OFF) App Settings → Agents & CLIs can enable or disable any CLI, install a missing stock CLI with its vetted install command, and add, edit or remove custom CLIs. Six new endpoints back it (`GET`/`POST /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id`), documented in `docs/api-reference.md`. Every write is refused while the switch is off, is admin-only in multi-user mode, is serialized on one queue, and refuses to overwrite a `clis.json` that does not parse or has group/world permission bits. A custom entry is re-validated through the same schema as the stock ones and its install text is never executed. `shell` cannot be disabled. The Run menu and the welcome screen are now built from the enabled catalogue, so the welcome screen also offers Codex, Shell and any custom CLI, and the stock Claude entry is labelled "Claude Code".
|
||||
|
||||
Models: Opus 5.5 (`claude-opus-5-5`, 1M context capable) is offered in App Settings → Models and in task routing (#480).
|
||||
|
||||
Self-update: on a macOS `launchd-daemon` install, a Homebrew node upgrade could leave `update-status.json` stuck at `queued`, which made every later update fail with "An update is already in progress." The updater now falls back to `node` on PATH when the server's own node binary is gone, and an in-flight status that has not been written for 15 minutes is failed on the next read. A graceful shutdown that hangs is now force-exited after 10 s (and the launchd updater SIGKILLs a server that has not exited after 30 s), so launchd can start the new build instead of leaving the service down (#478). Both fixes protect updates that start FROM this release.
|
||||
|
||||
Session Manager (Cmd+K): rows keep their `mode`, `claudeSessionId` and `resumeId`, so the ⋯ menu's Resume session relaunches a Codex row as Codex on its own conversation, and the mode badge shows as it does on the home list (#477).
|
||||
|
||||
Maintainer fixes applied while landing #457: renaming a tab to the name it already has (the Session Options field saves on blur) is now a no-op, so it no longer pins the placeholder as the `/resume` title again; Docker sessions skip the transcript title sync, since their transcript lives in the container; and the agent skill's messaging examples no longer use a `w<N>-` name as the peer name.
|
||||
|
||||
Tests: the suite strips every inherited `CODEMAN_*` variable, so running it inside a Docker Compose deployment no longer writes into the deployment's real case root (#479).
|
||||
|
||||
### Thanks
|
||||
- @opticon454 for CLI management (#476), the last piece of the CLI registry, with every review item answered in one round, and for splitting the test isolation fix out into #479.
|
||||
- @shenlvkang-collab for the `/resume` title fix (#457) and the careful diagnosis behind it.
|
||||
- @julian3xl for the Session Manager row fix (#477), their first contribution.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 69a7128: fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
|
||||
|
||||
## 1.32.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||
</p>
|
||||
|
||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||
@@ -50,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
|
||||
- **Self-hosted and private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-tour-20261010.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -381,14 +381,6 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
|
||||
|
||||
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
|
||||
|
||||
### Tile Grid
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tile-grid-20261009.gif" alt="Tile grid: the Tiles button opens six live sessions side by side (DeepSeek Harness, Claude Code, Codex, a shell, OpenCode and Pi), and a second click returns to a single session" width="800">
|
||||
</p>
|
||||
|
||||
Watch and drive up to **six sessions side by side** in one window. Click **Tiles** in the header (or press `Ctrl+Shift+G`) and your sessions open as a grid of live terminals: every tile takes your keystrokes and shows its agent's logo, model and state in its header. Right-click **Tiles** to choose 2, 4 or 6 tiles, and drag a tile by its header to move it. Click **Tiles** again to return to a single session; the grid is remembered for next time. Desktop only (a window about 1180px wide or more). Full guide: [Tile Grid](docs/wiki/Tile-Grid.md).
|
||||
|
||||
### Persistent Sessions
|
||||
|
||||
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
|
||||
@@ -422,7 +414,7 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
### Tab Alerts
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-tab-states-20261010.png"><img src="docs/images/codeman-tab-states-20261010.gif" alt="Tab states, annotated: a working tab with a spinning green ring, a red tab blocked on the agent's question shown below it, and a yellow tab whose turn is done, both alert tabs breathing" width="900"></a>
|
||||
<img src="docs/images/tab-alerts-glow-20260815.gif" alt="Session tabs: a regular active tab beside a yellow waiting-for-input tab and a red needs-decision tab, both with a breathing glow" width="900">
|
||||
</p>
|
||||
|
||||
Every tab tells you its state at a glance. A running session keeps its green status dot. When a session stops and waits for input, its tab turns **yellow**: steady ring, tinted background, yellow dot, with a slow breathing glow on top. When a permission prompt or question is **blocking** the agent, the tab turns **red** with a faster pulse. The base tint never blinks off, so even a split-second glance (or a screenshot) reads the true state; the ring stays visible while the tab is selected, and a page reload re-arms pending alerts from the server, so a blocked session can never hide behind a fresh-looking tab.
|
||||
@@ -448,11 +440,8 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## More Features
|
||||
|
||||
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
|
||||
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode.
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
|
||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||
@@ -735,10 +724,6 @@ For AI agents and automation that control Codeman without a browser: an agent th
|
||||
|
||||
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
|
||||
</p>
|
||||
|
||||
#### Step 1: install it
|
||||
|
||||
| How | Command | Scope |
|
||||
@@ -944,24 +929,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.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tiles-crt-stats-20261010.gif" alt="Codeman 平铺视图:六个实时智能体(DeepSeek Harness、Claude Code、Pi、Codex、OpenCode 和一个 shell)以 CRT 动画开启与关闭,顶部实时显示 CPU、内存和 Claude 套餐用量" width="800">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||
@@ -382,14 +382,6 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
|
||||
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
||||
|
||||
### 平铺网格
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/tile-grid-20261009.gif" alt="平铺网格:点击平铺按钮,六个实时会话并排打开(DeepSeek Harness、Claude Code、Codex、一个 shell、OpenCode 和 Pi),再点击一次即回到单个会话" width="800">
|
||||
</p>
|
||||
|
||||
在一个窗口里并排查看和操作最多**六个会话**。点击顶栏的**平铺**按钮(或按 `Ctrl+Shift+G`),会话会以实时终端网格的形式打开:每个窗格都能直接接收键盘输入,并在窗格标题栏中显示智能体的图标、模型和状态。右键单击**平铺**可选择 2、4 或 6 个窗格,按住窗格标题栏拖动即可移动窗格。再次点击**平铺**即回到单个会话,网格会被记住,下次直接恢复。仅限桌面端(窗口宽度约 1180px 以上)。完整说明:[Tile Grid](docs/wiki/Tile-Grid.md)(英文)。
|
||||
|
||||
### 持久化会话
|
||||
|
||||
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||
|
||||
@@ -20,9 +20,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',
|
||||
@@ -34,19 +31,8 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/terminal-tile-scroll.browser.test.ts',
|
||||
'test/shift-enter-keypress.browser.test.ts',
|
||||
'test/key-tester.browser.test.ts',
|
||||
'test/webhook-settings.browser.test.ts',
|
||||
'test/case-custom-path.browser.test.ts',
|
||||
'test/doctor-settings.browser.test.ts',
|
||||
'test/git-status.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
'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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,12 +15,6 @@ TZ=Australia/Perth
|
||||
# this value rebuilds the image with a matching account.
|
||||
CODEMAN_RUNTIME_USER=codeman
|
||||
|
||||
# Optional Git identity for commits made by Codeman and Docker-case agents. These values
|
||||
# are written to each image's system Git configuration when it is rebuilt, so
|
||||
# deployments can configure a consistent default. Set both values together.
|
||||
# GIT_USER_NAME=
|
||||
# GIT_USER_EMAIL=
|
||||
|
||||
# Required. Persistent Codeman application data, CLI credentials, and session
|
||||
# state are stored here on the host and mounted at the runtime account's home
|
||||
# directory in the container.
|
||||
|
||||
@@ -67,27 +67,6 @@ two volumes are removed, by name within this Compose project; any volume a
|
||||
`docker-compose.override.yml` adds is left alone, and application data and
|
||||
case workspaces are host bind mounts, never touched either way.
|
||||
|
||||
## Git commit identity
|
||||
|
||||
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
|
||||
|
||||
```sh
|
||||
GIT_USER_NAME='Your Name'
|
||||
GIT_USER_EMAIL='you@example.com'
|
||||
```
|
||||
|
||||
Compose passes the values to the Codeman server build, and to the server process
|
||||
when it builds Docker-case agent images. Both images write the pair to Git's
|
||||
system configuration during their build, so commits retain the same identity
|
||||
after a container or agent image is recreated. Set both values together; an
|
||||
image build with only one value fails rather than using a partial identity. An
|
||||
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
|
||||
the server image's system-level default.
|
||||
|
||||
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
|
||||
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
|
||||
server container, then recreate any Docker cases that should use it.
|
||||
|
||||
## Private repositories (GitHub and Azure DevOps)
|
||||
|
||||
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
|
||||
|
||||
@@ -17,7 +17,6 @@ FROM node:22-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
tmux \
|
||||
ripgrep \
|
||||
curl \
|
||||
@@ -127,10 +126,6 @@ RUN set -eux; \
|
||||
# A different order is a different RUN string, which is a different layer hash and
|
||||
# so a needless cache miss between a bare `docker build` and a scripted one.
|
||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
||||
&& npm cache clean --force
|
||||
|
||||
@@ -254,21 +249,6 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
# Docker cases have a fresh, container-owned home directory. Declare the
|
||||
# optional identity here so changing it invalidates only this final layer, then
|
||||
# configure Git's system defaults. A user-level config still takes precedence.
|
||||
ARG GIT_USER_EMAIL=
|
||||
ARG GIT_USER_NAME=
|
||||
RUN set -eux; \
|
||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
git config --system user.name "${GIT_USER_NAME}"; \
|
||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||
fi
|
||||
|
||||
USER agent
|
||||
WORKDIR /home/agent
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ services:
|
||||
dockerfile: docker/server.Dockerfile
|
||||
args:
|
||||
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
|
||||
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||
GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||
PGID: ${PGID:-1000}
|
||||
PUID: ${PUID:-1000}
|
||||
image: ${CODEMAN_IMAGE}
|
||||
@@ -34,10 +32,6 @@ services:
|
||||
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
||||
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
||||
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
||||
# Passed through only so Codeman can use the same identity when it builds
|
||||
# the Docker-case agent image.
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
||||
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
||||
# defaults to empty rather than requiring a line in every .env.
|
||||
|
||||
@@ -39,7 +39,6 @@ RUN apt-get update \
|
||||
curl \
|
||||
g++ \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
make \
|
||||
openssh-client \
|
||||
procps \
|
||||
@@ -213,28 +212,13 @@ RUN set -eux; \
|
||||
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
||||
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
||||
# system directories for the root part of the start.
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
||||
ENV PATH=$PATH:/opt/codeman-cli/bin
|
||||
# CLIs installed at runtime (Settings -> CLIs, npm redirected to ~/.local by installEnv()) live on the
|
||||
# persistent home mount, so they survive a container recreate. Appended for the same reason as above.
|
||||
ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin
|
||||
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
|
||||
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
|
||||
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
|
||||
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
|
||||
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
|
||||
# already carries it for the same reason (#352). It lives in the same
|
||||
# runtime-writable prefix as the CLIs, so a session can update it in place.
|
||||
RUN npm install --global \
|
||||
@anthropic-ai/claude-code@2.1.258 \
|
||||
@google/gemini-cli@0.58.0 \
|
||||
@openai/codex@0.152.1 \
|
||||
opencode-ai@1.18.26 \
|
||||
pnpm@12.6.0 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||
@@ -302,21 +286,6 @@ EXPOSE 3000
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Declare the optional identity immediately before configuring it so a change
|
||||
# invalidates only this final layer. This is declarative setup: a persisted
|
||||
# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values.
|
||||
ARG GIT_USER_EMAIL=
|
||||
ARG GIT_USER_NAME=
|
||||
RUN set -eux; \
|
||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
git config --system user.name "${GIT_USER_NAME}"; \
|
||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||
fi
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
CMD ["node", "dist/index.js", "web"]
|
||||
|
||||
@@ -45,13 +45,6 @@ payload return `{ "success": true, "data": {} }`.
|
||||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||||
>
|
||||
> **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
|
||||
> `GET /api/screenshots/:name` keep working but log a one-time warning on first
|
||||
> use. They are removed in a later MAJOR, after at least one MINOR release that
|
||||
> carries this warning (see `docs/versioning-policy.md`). To hand
|
||||
> a file to an agent, use `POST /api/sessions/:id/paste-image`, which saves it into
|
||||
> that session's workspace.
|
||||
|
||||
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
||||
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
||||
@@ -318,15 +311,6 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
|
||||
turn that never started. Verified live; this is the most common silent failure on
|
||||
this endpoint.
|
||||
|
||||
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
|
||||
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
|
||||
a separate key, and the server re-presses Enter while the prompt is still visibly
|
||||
sitting on the composer. Written straight into the pane in one piece, a prompt of
|
||||
about a hundred characters or more is taken as a paste by Claude Code, its `\r`
|
||||
becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other
|
||||
input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps
|
||||
the raw write, and an explicit `"useMux": false` forces it.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
@@ -452,41 +436,6 @@ count against the same 16, not 16 of each. An abandoned request no longer holds
|
||||
slot, because the routes release the waiter when the client disconnects, but a
|
||||
client that opens many concurrent waits against one session will still hit the cap.
|
||||
|
||||
## Terminal capture (`GET /api/v1/sessions/:id/terminal`)
|
||||
|
||||
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`.
|
||||
|
||||
| Query | Meaning |
|
||||
|---|---|
|
||||
| `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. |
|
||||
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
|
||||
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
|
||||
|
||||
## 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
|
||||
@@ -512,32 +461,6 @@ also pure decoration: it confers no permission, and a child is unaffected by its
|
||||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||||
unresolved) and survives a server restart.
|
||||
|
||||
## Session model (`displayModel`)
|
||||
|
||||
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
|
||||
session runs as far as the server knows it, for the web UI's session headers:
|
||||
|
||||
```json
|
||||
"displayModel": { "model": "qwen3.8-27b", "source": "screen" }
|
||||
```
|
||||
|
||||
`source` is where it came from, strongest first:
|
||||
|
||||
| `source` | Meaning |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. |
|
||||
| `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. |
|
||||
| `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. |
|
||||
| `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. |
|
||||
| `launch` | What the session was launched with (`--model`, the app-wide default, `<cli>Config.model`); nothing has reported since. |
|
||||
|
||||
Between `statusline` and `screen` the newest report wins. The field is absent when no
|
||||
model is known (a shell, a CLI that reports none and was launched without one). `model`
|
||||
is display text from a pane or a CLI report: control characters are stripped and it is at
|
||||
most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is
|
||||
persisted and restored after a server restart until the next report replaces it; a
|
||||
`config` value is read again at every pane start, attach and relaunch instead.
|
||||
|
||||
## Approvals Inbox
|
||||
|
||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||
@@ -768,46 +691,6 @@ normal `caseName`/`mode`/etc. body)
|
||||
jarring than a full relaunch, and folding it into the one-shot path is
|
||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||
|
||||
## Creating a case in a custom folder
|
||||
|
||||
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
|
||||
|
||||
The target is judged before anything is written:
|
||||
|
||||
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
|
||||
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
|
||||
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
|
||||
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
|
||||
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
|
||||
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
|
||||
|
||||
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
|
||||
|
||||
## Git status
|
||||
|
||||
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
|
||||
|
||||
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
|
||||
|
||||
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
|
||||
|
||||
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
|
||||
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
|
||||
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
|
||||
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
|
||||
|
||||
Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported.
|
||||
|
||||
`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only):
|
||||
|
||||
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
|
||||
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
|
||||
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
|
||||
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
|
||||
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
|
||||
|
||||
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
|
||||
|
||||
## CLI management
|
||||
|
||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||
@@ -821,45 +704,6 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
|
||||
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||
|
||||
## MCP server sync
|
||||
|
||||
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
|
||||
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
|
||||
|
||||
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).
|
||||
- `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`.
|
||||
- `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).
|
||||
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
|
||||
|
||||
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
|
||||
|
||||
## Webhook notifications
|
||||
|
||||
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | -------------------- | -------------------------------------------- | ----- |
|
||||
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
|
||||
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
|
||||
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
|
||||
|
||||
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
@@ -71,23 +71,17 @@ We tested three browser automation frameworks against the Codeman web UI:
|
||||
|
||||
## Test File Structure
|
||||
|
||||
### Ports
|
||||
### Port Allocation
|
||||
|
||||
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.
|
||||
- Never port 3000: that is the live instance.
|
||||
|
||||
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
|
||||
fixed ports 3180-3182.
|
||||
| Port Range | Test File |
|
||||
|------------|-----------|
|
||||
| 3150-3153 | browser-e2e.test.ts (existing) |
|
||||
| 3154 | file-link-click.test.ts |
|
||||
| 3155 | browser-playwright.test.ts |
|
||||
| 3156 | browser-puppeteer.test.ts |
|
||||
| 3157 | browser-agent.test.ts |
|
||||
| 3158-3160 | browser-comparison.test.ts |
|
||||
| 3180-3182 | scripts/browser-comparison.mjs |
|
||||
|
||||
### File Purposes
|
||||
|
||||
@@ -112,7 +106,7 @@ const browser = await chromium.launch({
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto(BASE_URL);
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Auto-waiting selectors
|
||||
await page.click('.btn-claude');
|
||||
@@ -146,7 +140,7 @@ const browser = await puppeteer.launch({
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto(BASE_URL);
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Manual waiting often needed
|
||||
await page.click('.btn-claude');
|
||||
@@ -192,7 +186,7 @@ function agentBrowserJson<T>(cmd: string): T {
|
||||
}
|
||||
|
||||
// Usage
|
||||
agentBrowser(`open ${BASE_URL}`);
|
||||
agentBrowser('open http://localhost:3000');
|
||||
agentBrowser('click ".btn-claude"');
|
||||
const title = agentBrowserJson<{title: string}>('get title');
|
||||
|
||||
@@ -252,14 +246,11 @@ npx playwright install chromium
|
||||
### 4. Wait for Server Startup
|
||||
|
||||
```typescript
|
||||
const server = new WebServer(0, false, true); // port 0 (the OS picks one), no TLS, testMode
|
||||
const server = new WebServer(PORT);
|
||||
await server.start();
|
||||
const BASE_URL = `http://localhost:${server.boundPort}`;
|
||||
await new Promise(r => setTimeout(r, 1000)); // Allow server to stabilize
|
||||
```
|
||||
|
||||
`boundPort` holds the real port only once `start()` has resolved. The `BASE_URL` used by the
|
||||
other snippets on this page is this one.
|
||||
|
||||
### 5. Clean Up Sessions
|
||||
|
||||
Track created sessions for cleanup:
|
||||
|
||||
@@ -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; // tab badge, e.g. 'CX'
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
@@ -46,14 +46,8 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
||||
// — how this CLI's pane shows work, work it started in the background, and a turn
|
||||
// that ended waiting for workers it will resume from
|
||||
// .modelDetect?: { screenLine, screenLines? }
|
||||
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
|
||||
// .launchDefaults?: { [launchParam]: settingsKey }
|
||||
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
|
||||
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
|
||||
// this CLI's pane shows work, and how it shows work it started in the background
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
@@ -62,45 +56,25 @@ interface CliEntry {
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 8, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), codex's ` <model> <effort> · ` footer (its last row, or the row above codex 0.162's indented hint row, so a two-row window), and opencode's composer agent row (`┃ Build <model> <provider>`, directly above the box's `╹` edge, eight rows because its home screen puts up to five rows of its own chrome below it). opencode's field is the model AND the provider, since only colour separates them on that row; the pattern takes the LAST such row in the window, so a composer-shaped row the agent prints higher up cannot stand in for it. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
|
||||
|
||||
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
|
||||
|
||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
||||
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
|
||||
to comment on a page the agent published, so Claude's pattern refuses any footer that
|
||||
carries it, and the idle alert goes out as usual.
|
||||
|
||||
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||
the `·` its footer joins items with. Codex pins
|
||||
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
||||
puts the row third from the bottom once the status line and the composer are counted, and
|
||||
fourth on codex 0.162+ at rest, where a `← for agents · ? for shortcuts` hint row sits under
|
||||
the status line (it disappears while a prompt is typed). So its entry declares
|
||||
`watchingLines: 4` and matches that row end to end. Both were measured against live panes
|
||||
rather than read out of a binary, which is the standard for adding a third.
|
||||
|
||||
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
|
||||
to wait for workers the CLI will resume from by itself. When background agents or an
|
||||
ultracode workflow are still running at turn end, Claude closes the turn with
|
||||
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
|
||||
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
|
||||
the words are still on screen after the workers report back and the follow-up turn ends.
|
||||
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
|
||||
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
|
||||
tests only the first row that starts in column 0, which is the newest transcript row. Claude
|
||||
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
|
||||
agent from holding its own session busy.
|
||||
puts the row third from the bottom once the status line and the composer are counted, so its
|
||||
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
|
||||
against live panes rather than read out of a binary, which is the standard for adding a
|
||||
third.
|
||||
|
||||
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||
@@ -128,10 +102,6 @@ sure its row is one the agent cannot write.
|
||||
|
||||
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
|
||||
|
||||
## The newline chord
|
||||
|
||||
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||
|
||||
## Arg-template safety
|
||||
|
||||
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
||||
@@ -263,16 +233,6 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
||||
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
|
||||
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
|
||||
|
||||
## 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`.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
## See also
|
||||
|
||||
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
||||
|
||||
@@ -53,9 +53,7 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
|
||||
surfaces that same line as the install error. `npm install -g pnpm` (or
|
||||
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
||||
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
||||
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
|
||||
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
|
||||
button works there too.
|
||||
alongside `dsh`.
|
||||
|
||||
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
||||
margin the most used community TUI, it is MIT, and it implements the status
|
||||
@@ -201,18 +199,6 @@ not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
|
||||
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
||||
profile. That is also how you point dsh at a local or third-party provider.
|
||||
|
||||
Codeman does READ the route, for display only: a session header names the model
|
||||
the TUI's status line draws, and while it draws none (the status bar's model
|
||||
field switched off, or not painted yet) the model the session's route config
|
||||
pins (`src/deepseek-route-config.ts`). That is dsh-TUI's own rule: the last of
|
||||
`profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying
|
||||
`config` for the `dsh-tui` row, and only when it names BOTH `provider` and
|
||||
`model`; a half-pinned route is dropped whole by the TUI and shows nothing here.
|
||||
`settings.yaml`'s `agent-default-model` is the headless default and is not read.
|
||||
The reader never writes, follows no symlink out of the dsh home, and returns the
|
||||
model id alone. The TUI can still reject a pinned route against its provider's
|
||||
model catalog at startup; the status line, when on, then shows what it chose.
|
||||
|
||||
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
||||
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
||||
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
||||
|
||||
@@ -83,8 +83,6 @@ Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.jso
|
||||
|
||||
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
||||
|
||||
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||
|
||||
@@ -6,8 +6,6 @@ For the Compose configuration, environment settings, storage migration, and macv
|
||||
|
||||
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
||||
|
||||
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
|
||||
|
||||
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -338,42 +338,6 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
|
||||
codeman status | list | attach <path> codeman doctor
|
||||
```
|
||||
|
||||
### Opening a session from your own page
|
||||
|
||||
To send someone from your page to one session, link to the dashboard with the
|
||||
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
|
||||
dashboard selects that tab when it loads. It also removes the fragment from its
|
||||
own URL, so a later link to the same session still counts as a change.
|
||||
|
||||
Keep reusing one named window to make later links fast:
|
||||
|
||||
```js
|
||||
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
|
||||
```
|
||||
|
||||
When that window already shows the dashboard, only the fragment differs. The
|
||||
browser therefore keeps the page loaded, and the dashboard switches tabs without
|
||||
reloading it. A session the window has shown before appears at once. A session
|
||||
your page has only just created may not be listed yet, so the dashboard waits
|
||||
for its `session:created` event and selects it then. That wait lasts at most 30
|
||||
seconds: a link whose session never appears (a closed session, a typo, or in
|
||||
multi-user mode another user's session) is dropped with a "Session not found"
|
||||
notice. Clicking another tab, going Home or opening a web tab also ends the
|
||||
wait, so a session that turns up later never takes the screen from the person.
|
||||
|
||||
When your page holds the window reference (`const win = window.open(...)`),
|
||||
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
|
||||
without a reload, but adds no history entry, so Back in the dashboard window
|
||||
does not turn into a silent no-op.
|
||||
|
||||
Following a link does not count as someone looking at the session, so it
|
||||
leaves the session's idle alert in place. The alert clears when the person
|
||||
clicks the tab or types into the session. A link to a session that is popped
|
||||
out into its own window asks that window to come forward, as clicking its tab does.
|
||||
|
||||
A link to `/session/<id>` opens a page showing that session alone, and that
|
||||
page loads from scratch for every link.
|
||||
|
||||
## Seam 4: Hooks
|
||||
|
||||
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 774 KiB |
|
Before Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
@@ -158,7 +158,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
|
||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||
@@ -228,7 +228,7 @@ These operate directly on `users.json` via `user-store.ts` (no server needed), h
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||
| Instance isolation | `users.json` and the audit log via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||
|
||||
|
||||
@@ -163,13 +163,9 @@ remote user's home, so resolving locally would pin a stranger's id. See
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook.** Idle detection reads the screen instead: the
|
||||
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
|
||||
the idle check, and the status bar's spinner plus elapsed time (` ⠼ 14s > ⬢ …`)
|
||||
or the `⎋ Working…` row as the working line, measured on omp 18.8.6 and 18.0.11.
|
||||
Without it an omp session that had started a turn never left `busy`. If omp ever
|
||||
ships a hooks system, a Codeman hook POSTing to `/api/hook-event` would still be
|
||||
the highest-value follow-up.
|
||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
||||
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
|
||||
POSTing to `/api/hook-event` would be the highest-value follow-up.
|
||||
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
|
||||
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
|
||||
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||
|
||||
@@ -920,8 +920,6 @@ const mode = cmd.includes('opencode') ? 'opencode' : 'claude';
|
||||
|
||||
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
|
||||
|
||||
> **Update 2026-10-09 (working/idle shipped, from measured data):** the registry entry now declares `capabilities.workDetect` for opencode, measured on a live opencode 1.3.0 pane (pane captures every 250-300 ms through real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream). Every composer row starts with a `┃` bar, which arms the shared screen-probed idle check; a running turn puts an 8-cell knight-rider spinner (`⬝■■■■■■⬝ esc interrupt`) at the head of the footer row, redrawn about every 40 ms, and `[⬝■]{8}` is the working line. The label is not the anchor: tmux ships `esc` and `interrupt` as separate words joined by cursor moves, and below about 45 columns the footer wraps it. At rest the TUI is silent (no cursor or timer redraws), and a pending permission prompt replaces the composer and stops the spinner, so it reads as idle. Before this, a turn that ran a tool latched the session `busy` for good (the tool row's braille spinner tripped the generic spinner detector, and nothing ever armed the idle check). Token parsing and the ANSI content filter remain deferred.
|
||||
|
||||
### Goal
|
||||
Detect OpenCode's state from terminal output (idle, working, ready).
|
||||
|
||||
|
||||
@@ -223,13 +223,9 @@ command override instead.
|
||||
## Known gaps
|
||||
|
||||
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
|
||||
idle detection reads the screen: the registry entry's `workDetect` names pi's
|
||||
composer rule (`─`) as the glyph that arms the idle check and the spinner pi embeds
|
||||
in that rule while a turn runs (`── ⠏ Working ───`) as the working line, measured
|
||||
on pi 1.1.0. Without it a pi session that had started a turn never left `busy`,
|
||||
since pi never draws Claude's `❯`. Pi 0.84.0 shipped an `agent_settled` extension
|
||||
event that is a genuine idle signal; a Codeman pi extension using it is still the
|
||||
highest-value follow-up.
|
||||
idle detection falls back to output-stabilization like the other external CLIs.
|
||||
Pi 0.84.0 shipped an `agent_settled` extension event that is a genuine idle
|
||||
signal; a Codeman pi extension using it is the highest-value follow-up.
|
||||
- **No response viewer.** Pi writes JSONL v3 session files under
|
||||
`~/.pi/agent/sessions/`; nothing reads them yet.
|
||||
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
|
||||
|
||||
@@ -66,31 +66,6 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||
apply.
|
||||
|
||||
## Oversized input (issue #484)
|
||||
|
||||
Delivery has a third outcome besides "applied" and "retry": **refused for good**.
|
||||
Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB,
|
||||
`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before
|
||||
#484 the client treated that like a transient failure, so an oversized paste sat
|
||||
at the head of the queue, was re-sent every 2 s forever, blocked every later
|
||||
input for the session, and came back from localStorage on each reload.
|
||||
|
||||
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
|
||||
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
|
||||
go out in seq order, so the PTY sees one contiguous stream. A paste over
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
|
||||
split), is refused with a toast and never queued.
|
||||
- The WebSocket answers an oversized sequenced frame with
|
||||
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
|
||||
client that predates `err` reads it as a plain ACK and drops it too.
|
||||
- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient:
|
||||
an expired login delivers once the user signs in again).
|
||||
- `_loadReliableState()` prunes persisted frames over the limit, so a queue
|
||||
poisoned by an older build heals on the first load after upgrading.
|
||||
- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's
|
||||
`COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by
|
||||
`test/input-size-limit.test.ts`.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||
@@ -104,5 +79,3 @@ across the narrow restart window.
|
||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||
- `test/input-size-limit.test.ts`: one input limit on both sides, frame
|
||||
splitting, and dropping (never retrying) a frame refused for good (#484).
|
||||
|
||||
@@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal
|
||||
- The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local).
|
||||
- The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom.
|
||||
- 40ms SGR coalescing: never send per-event writes to the server.
|
||||
- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`.
|
||||
- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified.
|
||||
- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three.
|
||||
- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`).
|
||||
- The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened.
|
||||
|
||||
## Testing (per repo rules)
|
||||
|
||||
@@ -354,9 +354,8 @@ is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download c
|
||||
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
||||
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
||||
is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in
|
||||
`src/attachment-registry.ts`: images, pdf/docx/pptx/xlsx, audio/video, md/txt and
|
||||
other text), not realpath containment.
|
||||
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
|
||||
realpath containment.
|
||||
|
||||
Two registration paths, with **different trust**:
|
||||
|
||||
@@ -533,16 +532,6 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
|
||||
---
|
||||
|
||||
## 10c. Webhook notifications (outbound channel)
|
||||
|
||||
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
|
||||
|
||||
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
|
||||
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
|
||||
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||
|
||||
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it
|
||||
> reconnects after a drop, delivers input exactly once, has clickable paths
|
||||
> and image paste, sizes its PTY without a floor and adopts `zc` columns, and
|
||||
> the app-level terminal shortcuts follow the focused pane. Ctrl+W no longer
|
||||
> closes anything (Close Session has no default key).
|
||||
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
@@ -92,15 +84,7 @@ view needs a wide viewport). So:
|
||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||
instance with no overlay is exactly how Codeman behaved before the local-
|
||||
echo overlay existed for touch devices — normal, not degraded, for a
|
||||
keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js,
|
||||
which has gained three pieces of the primary pane: hollow-buffer wheel
|
||||
paging (#555) and the desktop click report for a CLI with
|
||||
`cliMouseTracking` on, both through the primary pane's gates aimed at the
|
||||
tile, and its own keyCode-229 soft-keyboard controller
|
||||
(terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's
|
||||
edit-based diff, so an Android autocorrect is not sent twice). The 1180px
|
||||
width gate is all that keeps a phone out, and a wide Android tablet clears
|
||||
it. See that file's fileoverview.)
|
||||
keyboard-and-mouse user.
|
||||
|
||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||
parity is a scoped v2 (extract the shared logic already once you have two
|
||||
|
||||
@@ -82,7 +82,6 @@ This is exactly how `command-palette` already behaves: it is a full registry ent
|
||||
|
||||
- The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on.
|
||||
- `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change.
|
||||
⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged.
|
||||
- Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface.
|
||||
|
||||
### 2.6 Helpers that already exist and should be reused
|
||||
@@ -246,7 +245,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive
|
||||
| Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts |
|
||||
| macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through |
|
||||
| Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path |
|
||||
| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
||||
| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies |
|
||||
| Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe |
|
||||
| Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) |
|
||||
|
||||
@@ -282,7 +281,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30
|
||||
3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before.
|
||||
4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection.
|
||||
5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt.
|
||||
6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16).
|
||||
6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select.
|
||||
7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal.
|
||||
8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key).
|
||||
|
||||
|
||||
@@ -40,10 +40,6 @@ A **MAJOR** bump is required to break any of these after 1.0:
|
||||
optional fields, new error codes, new SSE events) are non-breaking; breaking
|
||||
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
|
||||
is kept working for the bundled UI.
|
||||
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
|
||||
`#session=<id>` fragment selects that session if this client can see it. The
|
||||
fragment name and that meaning are stable; see
|
||||
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
|
||||
|
||||
## What SemVer does NOT cover (internal surfaces — may change in any release)
|
||||
|
||||
@@ -57,10 +53,8 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
|
||||
programmatically is not supported (there is no stable library entry point).
|
||||
3. **Experimental / opt-in features**, regardless of the app's version:
|
||||
Gesture Control (beta), Agent Teams
|
||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge
|
||||
(`window.CodemanHost.openWindow` / `closeWindow` / `focusWindow`), and
|
||||
anything labeled experimental in the UI or docs. These may change or be
|
||||
removed at any time.
|
||||
(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental
|
||||
in the UI or docs. These may change or be removed at any time.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
|
||||
@@ -69,14 +69,14 @@ output. The other CLIs expose no equivalent.
|
||||
| Respawn cycling and unattended runs | Yes | Yes |
|
||||
| Cron jobs | Yes | Yes |
|
||||
| Docker cases, remote SSH cases | Yes | Yes |
|
||||
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
|
||||
| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
|
||||
| Auto-resume when a usage limit resets | Yes | No |
|
||||
| Plan usage chip | Yes | No |
|
||||
| Approvals Inbox | Yes | DeepSeek yes; others no |
|
||||
| Read My Mind | Yes | No |
|
||||
| Ralph loop and its task tracker | Yes | No |
|
||||
| Subagent and team windows | Yes | No |
|
||||
| Model, effort, advisor, and ultracode controls | Yes | No |
|
||||
| Model, effort, and ultracode controls | Yes | No |
|
||||
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
|
||||
| The bundled agent skill | Yes | No |
|
||||
|
||||
@@ -96,9 +96,6 @@ The defaults you will care about, all under **App Settings**:
|
||||
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
|
||||
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
|
||||
environment variable, because that would hard-lock it and block in-session switching.
|
||||
- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points,
|
||||
via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft
|
||||
default: `/advisor` switches it or turns it off inside the session.
|
||||
- **Startup permission mode** (Agents & CLIs section). The default is
|
||||
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
|
||||
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
|
||||
@@ -121,24 +118,10 @@ Renders its own TUI, so Codeman treats readiness as output stabilization rather
|
||||
watching for a prompt marker. Requires tmux, with no direct-PTY fallback, because its
|
||||
environment is injected through socket-scoped `tmux setenv` rather than the command line.
|
||||
|
||||
Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at
|
||||
the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working
|
||||
session from an idle one. A pending permission prompt shows as idle, since it is waiting on
|
||||
you. Before 1.40.0 an OpenCode session that had run a tool showed as working for good.
|
||||
|
||||
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
|
||||
|
||||
### Codex
|
||||
|
||||
App Settings has synced **Default Codex model** and **Default Codex reasoning effort**
|
||||
controls. Enter a model ID supported by your Codex provider; available reasoning levels
|
||||
depend on the model and CLI version. Empty defaults use Codex's own configuration.
|
||||
The defaults apply to local Codex sessions started from the Run menu, from Resume, and through
|
||||
`POST /api/sessions` or `/api/quick-start`; scheduled (cron) jobs do not use them.
|
||||
Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence.
|
||||
Custom model endpoints, Docker containers and remote host command overrides keep their own settings.
|
||||
Changing a default affects new sessions and does not edit Codex configuration files.
|
||||
|
||||
Two behaviours that are deliberate and worth knowing:
|
||||
|
||||
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
|
||||
@@ -162,11 +145,6 @@ needs `GOOGLE_CLOUD_PROJECT`, `GOOGLE_APPLICATION_CREDENTIALS`, and
|
||||
`GOOGLE_GENAI_USE_VERTEXAI`. That is the loosest allowlist entry in Codeman and it affects
|
||||
only the CLI you spawned yourself.
|
||||
|
||||
Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line
|
||||
(`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool
|
||||
confirmation that waits for you shows as idle. Before 1.40.0 a Gemini session showed as
|
||||
working for good after its first turn.
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of its state
|
||||
@@ -188,10 +166,6 @@ Pi needs the opposite instincts from every other CLI here.
|
||||
`HF_TOKEN`, and so on) share no common prefix, and the environment allowlist is global
|
||||
rather than per mode, so admitting them for Pi would widen the allowlist for every mode at
|
||||
once. They stay out.
|
||||
- **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it
|
||||
puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads
|
||||
that to tell working from idle. Before 1.40.0 a Pi session that had started a turn showed
|
||||
as working for good.
|
||||
|
||||
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
|
||||
|
||||
@@ -245,9 +219,7 @@ documented default approval mode is `yolo`, so an OMP pane auto-approves tool us
|
||||
flag from Codeman; change that in OMP's own config, not here.
|
||||
|
||||
OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
|
||||
same conversation with `--continue`. Codeman tells working from idle by reading OMP's status
|
||||
bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.40.0
|
||||
an OMP session that had started a turn showed as working for good.
|
||||
same conversation with `--continue`.
|
||||
|
||||
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
||||
|
||||
|
||||
@@ -42,29 +42,17 @@ npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax
|
||||
npm run check:browser-excludes
|
||||
npm test # the gate, exactly what CI runs
|
||||
npm test -- test/<file>.test.ts # one file
|
||||
npm test -- test/<file>.test.ts # one file, the normal way
|
||||
npm run test:ci # the full CI sweep
|
||||
```
|
||||
|
||||
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
||||
machine-dependent) and blocks a push that would fail them. It skips itself when you push
|
||||
something other than the checked-out HEAD, or when the tree has uncommitted changes the
|
||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||
`pre-push` hook of your own is never overwritten.
|
||||
|
||||
`npm test` runs the same suite CI runs, so a green run locally means a green run there. It
|
||||
leaves out three suites that cannot pass on an arbitrary machine, each with its own command:
|
||||
`npm run test:browser` (Playwright, Chromium and a live server), `npm run test:mobile` (the
|
||||
same plus environment-specific screenshot baselines) and `npm run test:perf` (wall-clock
|
||||
benchmarks for an otherwise idle machine). Expect those to fail where the machine cannot
|
||||
provide what they need; that means "not runnable here", not a regression.
|
||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
||||
|
||||
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.
|
||||
tests cannot touch real sessions. If you add a test that binds a port, pick a unique one at
|
||||
3150 or above, and never 3000.
|
||||
|
||||
## Finding your way around
|
||||
|
||||
|
||||
@@ -15,12 +15,11 @@ Codeman-side configuration:
|
||||
- Per-case toggles (Agent Teams, 1M Opus context).
|
||||
- Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays).
|
||||
|
||||
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
|
||||
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
|
||||
Three ways to get one, all under **+** next to the case picker:
|
||||
|
||||
| How | Result |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
|
||||
@@ -147,12 +147,6 @@ in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
|
||||
`docker/README.md` ("Private repositories") has the details and the matching switches for
|
||||
the server image.
|
||||
|
||||
To give agents a fixed Git commit identity, set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and
|
||||
`CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together in that same environment (in the Docker
|
||||
deployment, set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` instead, which feeds
|
||||
both images). An existing `codeman/agent:base` only picks it up after a `--no-cache` rebuild
|
||||
and recreated case containers; `docker/README.md` ("Git commit identity") has the details.
|
||||
|
||||
## Isolation
|
||||
|
||||
Every container runs hardened by default:
|
||||
|
||||
@@ -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
|
||||
@@ -220,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
|
||||
## Lineage
|
||||
|
||||
A create request can name the session that spawned it, through a body field or a header, and
|
||||
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
|
||||
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
|
||||
|
||||
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
||||
failing the spawn, because a cosmetic field must never break a worker.
|
||||
|
||||
@@ -144,8 +144,6 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
|
||||
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
|
||||
curl -s "$API/api/subagents" | jq # background agents
|
||||
curl -s "$API/api/search?q=deploy" | jq # cross-session search
|
||||
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
|
||||
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
|
||||
|
||||
# with ID set to a session id:
|
||||
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
|
||||
|
||||
@@ -62,9 +62,7 @@ codeman web # then open http://localhost:3000
|
||||
| Page | What it answers |
|
||||
| ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| [The Dashboard](The-Dashboard) | What is the UI telling me? |
|
||||
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
|
||||
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
||||
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
|
||||
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
||||
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
||||
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
|
||||
|
||||
@@ -9,16 +9,13 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | --------------------------------------------------------------- |
|
||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||
| `Ctrl+W` | Kill the active session. |
|
||||
| `Ctrl+Tab` | Next session. |
|
||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||
|
||||
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||
|
||||
## Terminal
|
||||
|
||||
| Shortcut | Action |
|
||||
@@ -39,22 +36,6 @@ from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||
|
||||
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
||||
|
||||
## Tile grid
|
||||
|
||||
| Shortcut | Action |
|
||||
| --------------------------- | ------------------------------------------------------------ |
|
||||
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile one place: into an empty slot, or swap. |
|
||||
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
|
||||
| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). |
|
||||
|
||||
While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the
|
||||
terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by
|
||||
default. See [Tile Grid](Tile-Grid).
|
||||
|
||||
## Everything else
|
||||
|
||||
| Shortcut | Action |
|
||||
|
||||
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
||||
enforces it.
|
||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||
- The desktop home tab rail, which needs a wide window.
|
||||
- Lineage lines, which are a desktop overlay.
|
||||
- Lineage arcs, which are a desktop overlay.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -6,16 +6,15 @@ opening the session.
|
||||
|
||||
## The signals, cheapest first
|
||||
|
||||
| Surface | Reaches you | Default |
|
||||
| ------------------------------ | ------------------------------------------------ | ------- |
|
||||
| Tab alert | While the dashboard is open | On |
|
||||
| Browser title flash | Another tab in the same browser | On |
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
|
||||
| Approvals Inbox | One queue across every session | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
| Surface | Reaches you | Default |
|
||||
| ---------------------- | ------------------------------------------------- | ------- |
|
||||
| Tab alert | While the dashboard is open | On |
|
||||
| Browser title flash | Another tab in the same browser | On |
|
||||
| Desktop notification | Another window on the same machine | Opt-in |
|
||||
| Push notification | Anywhere, even with no tab open | Opt-in |
|
||||
| Approvals Inbox | One queue across every session | Opt-in |
|
||||
| Phone overview | Phone home screen, NEEDS YOU section | On |
|
||||
| Away Digest | Afterwards, as a summary | Opt-in |
|
||||
|
||||
## Tab alerts
|
||||
|
||||
@@ -61,51 +60,6 @@ Setup:
|
||||
|
||||
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
|
||||
|
||||
## Webhooks: ntfy, Slack, Discord
|
||||
|
||||
**Opt-in, off by default. One channel for the whole server.**
|
||||
|
||||
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
|
||||
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
|
||||
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
|
||||
and for a team channel.
|
||||
|
||||
It carries the same events as push: permission prompts, questions, idle sessions, session
|
||||
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
|
||||
is included only when **Which events** is set to **Everything**; the default, **Needs
|
||||
attention**, skips it. A session that is watching its own work stays quiet here too.
|
||||
|
||||
Setup, in **App Settings → Notifications → Webhook**:
|
||||
|
||||
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
|
||||
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
|
||||
sessionId, sessionName, host, at }`.
|
||||
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
|
||||
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
|
||||
Send test saves anything you changed first, so it always tests what is on screen.
|
||||
|
||||
The status line under the group shows the last delivery: when it worked, or why it did not
|
||||
(an HTTP status, a timeout, a refused connection).
|
||||
|
||||
Behaviour worth knowing:
|
||||
|
||||
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
|
||||
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
|
||||
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
|
||||
never shows it again: once saved, the box is empty and the hint shows only the scheme and
|
||||
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
|
||||
(which also turns the channel off).
|
||||
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
|
||||
the only thing keeping strangers out.
|
||||
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
|
||||
Link-local and cloud-metadata addresses are refused, both when you save and when the
|
||||
message is sent, and redirects are not followed.
|
||||
- **Repeats are folded.** The same event for the same session within three seconds is sent
|
||||
once, so a flapping prompt cannot flood a channel.
|
||||
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
|
||||
the webhook, and it receives every user's session events (session names, tool names, error
|
||||
text). Point it somewhere every user would be comfortable with.
|
||||
|
||||
## The Approvals Inbox
|
||||
|
||||
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
|
||||
@@ -173,10 +127,6 @@ plain prose is not a dialog, so an agent that starts a monitor and then writes "
|
||||
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
||||
been quiet longer than the work it is waiting for should take.
|
||||
|
||||
An agent waiting for your comments on an artifact it published never counts as watching.
|
||||
Claude shows that as "1 Artifact comment monitor", but the agent hears nothing until you
|
||||
comment, so the session alerts you like any other quiet session.
|
||||
|
||||
## The phone overview
|
||||
|
||||
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
||||
@@ -198,8 +148,7 @@ It is the morning-after view for an overnight run. Enable its header button in
|
||||
## Recommended setup for unattended runs
|
||||
|
||||
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
|
||||
webhook to ntfy if no browser will ever be open.
|
||||
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
|
||||
3. Approvals Inbox on.
|
||||
4. Auto-resume on usage limit on, for each session you leave running. See
|
||||
[Keeping Agents Running](Keeping-Agents-Running).
|
||||
@@ -209,8 +158,7 @@ from the lock screen.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
|
||||
has no such requirement, since the server sends it.
|
||||
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
|
||||
- **iOS needs the home screen install.** A Safari tab will never receive push.
|
||||
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
|
||||
- **Approvals need real signals.** They are built on hook events, which Claude emits and
|
||||
|
||||
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
|
||||
|
||||
| Tab | Use it when |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
|
||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
|
||||
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
||||
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
||||
|
||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
||||
| To do this | Do that |
|
||||
| ------------------------- | ------------------------------------------------------------------- |
|
||||
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
||||
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
|
||||
| Close one session | `Ctrl+W`, or the tab's close control. |
|
||||
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
||||
| Stop everything | `tmux -L codeman kill-server`. |
|
||||
|
||||
|
||||
@@ -50,37 +50,20 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
|
||||
|
||||
### Header & Panels
|
||||
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
||||
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
||||
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
|
||||
committed and pushed. Click it for the Git window; see
|
||||
[Working With Files](Working-With-Files#git-changes). **Git status: group files
|
||||
by folder** (per device, on by default) shows changed files under collapsed folders in that
|
||||
window; off lists every file by its full path. **Git status: max repositories** (per device,
|
||||
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
|
||||
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
|
||||
long one git command may run before that repository is reported as unreadable; raise it for
|
||||
repositories on a slow network share.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
|
||||
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
|
||||
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go. **Tiles** is desktop-only the same
|
||||
way; it also enables the `Ctrl+Shift+G` grid toggle on this device. See
|
||||
[Tile Grid](Tile-Grid).
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
|
||||
This section also holds background-agent tracking, including whether to track agents for
|
||||
every session or only the active tab.
|
||||
@@ -90,48 +73,27 @@ 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). |
|
||||
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
|
||||
| Tab Layout | *Classic* (default): the single list as before. *By state*: a row each for needs you, waiting, working and idle, sections in the rail and sidebar. *By case*: one box per case. *Ledger*: an aligned column grid. See [The Dashboard](The-Dashboard#tab-layouts). |
|
||||
| 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. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
||||
| 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. |
|
||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. 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
|
||||
advisor segment. The cards and the switch compose into one model choice, so there is no
|
||||
separate "which one wins" question.
|
||||
Claude model cards, the 1M context window switch, and the thinking effort segment. The cards
|
||||
and the switch compose into one model choice, so there is no separate "which one wins"
|
||||
question.
|
||||
|
||||
Model, effort and advisor are all **soft defaults**: the model is written into the case's
|
||||
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`,
|
||||
`/effort` and `/advisor` inside a session override them at any time.
|
||||
|
||||
**Advisor** gives new Claude sessions Claude Code's
|
||||
[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude
|
||||
consults before committing to an approach, when an error keeps coming back, and before it
|
||||
calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor,
|
||||
which costs less than running the stronger model all the time. **Default** leaves it to
|
||||
whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not
|
||||
Bedrock or Vertex), and an advisor that ranks below the session's model is simply not
|
||||
attached.
|
||||
Model and effort are both **soft defaults**: the model is written into the case's
|
||||
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
|
||||
inside a session override them at any time.
|
||||
|
||||
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
|
||||
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
||||
@@ -148,21 +110,13 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
||||
| Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
|
||||
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
|
||||
| Nice priority / value | Runs agent processes at a lower CPU priority. |
|
||||
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
|
||||
| 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. |
|
||||
| Bypass approvals and sandbox | Pi's project trust. 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. |
|
||||
|
||||
### Notifications
|
||||
|
||||
Master toggle, browser notifications, push subscription, audio alerts, how long a
|
||||
corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and
|
||||
how long a desktop notification stays up before Codeman closes it (**Browser
|
||||
notification display time**, default 8; both per device, and your OS may close a
|
||||
desktop notification sooner), the idle
|
||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||
Master toggle, browser notifications, push subscription, audio alerts, and the idle
|
||||
threshold that decides when a quiet session counts as needing you. See
|
||||
[Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
### Voice
|
||||
@@ -179,10 +133,8 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
### System
|
||||
|
||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||
mode, the **Users** administration entry is injected here.
|
||||
Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the
|
||||
**Users** administration entry is injected here.
|
||||
|
||||
## Session Options
|
||||
|
||||
@@ -217,8 +169,6 @@ Some things are configured before the server starts, not in the UI:
|
||||
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
|
||||
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
|
||||
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
|
||||
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
|
||||
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 2 by default: one below the threadpool size minus one, so it follows `UV_THREADPOOL_SIZE` (4 unless set), and it is never allowed above that ceiling. A check you start by opening one case or session may use the one slot left above it. |
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Most of Codeman's UI is **opt-in**. A stock install shows a deliberately small h
|
||||
feature you read about here may simply not be on screen yet. Where that is the case, this
|
||||
page says so and names the setting.
|
||||
|
||||

|
||||

|
||||
|
||||
## Layout
|
||||
|
||||
@@ -15,7 +15,7 @@ page says so and names the setting.
|
||||
| **Header, left** | The "C" logo (goes home) and the session list, unless you moved it to the sidebar. |
|
||||
| **Header, right** | Status chips and panel buttons, most of them off by default. |
|
||||
| **Center** | The terminal for the active session, or the home screen when nothing is selected. |
|
||||
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counter. |
|
||||
| **Bottom toolbar** | Run, Stop, Run Shell, the case picker, and the instance counters. |
|
||||
| **Overlays** | Panels and modals: Respawn, Cron, Subagents, File Viewer, Settings. |
|
||||
|
||||
## Session list layout
|
||||
@@ -27,56 +27,18 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
|
||||
| Layout | Behaviour |
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
|
||||
| **Header tab strip** | The default. Wraps to a second row on desktop, 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. 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
|
||||
per device, so a sidebar on your desktop does not force one onto your phone.
|
||||
|
||||
## Tab layouts
|
||||
|
||||
**App Settings → Appearance → Tabs → Tab Layout** picks how the tabs are arranged. Per device.
|
||||
|
||||
| Layout | What it does |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **By state** | Groups the tabs by what each session needs from you (below). |
|
||||
| **By case** | One box per case, labelled with the case and its tab count. Inside a box, `w75-api-gateway` reads just `w75`. A case with one tab gets a box with a colour swatch. |
|
||||
| **Ledger** | The same list on an aligned column grid: equal cells, monospace names, a coloured bar on the left of each cell instead of the dot (yellow waiting, red needs you). Desktop header only. |
|
||||
| **Classic** (default) | The single list in tab order, as before. |
|
||||
|
||||
**By state** groups the tabs like this, most urgent on top:
|
||||
|
||||
| Group | Who is in it |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
|
||||
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
|
||||
| **Working** | A turn is running. |
|
||||
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
|
||||
|
||||
In the header each group is a row with its name and count on the left (Idle, the quiet
|
||||
default, carries no label); a group with more tabs than fit on one line continues on the
|
||||
next line. **State Order → Needs you at the
|
||||
bottom** turns the rows the other way up, so the needs-you row sits right above the
|
||||
terminal. Empty groups are not shown. These are the same states the phone overview and the
|
||||
desktop home rail use, and tabs move between groups on their own as their state changes.
|
||||
|
||||
Both groupings also apply to the vertical rail and the left sidebar, as labelled sections.
|
||||
Inside a group or a box tabs keep your tab order (on a rail sorted *By activity*, the
|
||||
activity order), and the `Alt+1` to `Alt+9` numbers never change. Dragging reorders tabs
|
||||
within a group or box. On a phone the strip stays a single scrolling row in group order,
|
||||
without labels or boxes. If you have named tab groups in the vertical rail, those take
|
||||
precedence there.
|
||||
|
||||
## Session tabs
|
||||
|
||||
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 |
|
||||
@@ -102,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
||||
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
||||
| Close | The tab's close control (no key by default) |
|
||||
| Close | `Ctrl+W` |
|
||||
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
||||
|
||||
Tabs can also be dragged to reorder.
|
||||
@@ -122,18 +84,13 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
|
||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||
|
||||
### Lineage lines
|
||||
### Lineage arcs
|
||||
|
||||
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
||||
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
||||
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
|
||||
a tab draws its own family thicker and brighter. A dashed branch means that child is
|
||||
working. It is how a fan-out of eight workers stays readable.
|
||||
a coloured arc under the strip connecting parent to child, with one colour per child. It is
|
||||
how a fan-out of eight workers stays readable.
|
||||
|
||||
While any tab has spawned another, the strip keeps a little extra room between rows for the
|
||||
lines, so switching tabs never changes the header height.
|
||||
|
||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
|
||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
|
||||
skipped for tabs scrolled out of the strip.
|
||||
|
||||
## Header controls
|
||||
@@ -145,7 +102,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
|
||||
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
||||
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
|
||||
| CPU / MEM bars | On | Server resource use. |
|
||||
| File Viewer | On | Toggles the file browser panel. |
|
||||
| Settings gear | Always on | App Settings. |
|
||||
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
|
||||
@@ -161,33 +118,12 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Cron ⏰ | Off | Scheduled jobs. |
|
||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||
| Tiles | On, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). |
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
### Header Stats Style
|
||||
|
||||
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
|
||||
(**App Settings → Header & Panels → Header Stats Style**, per device, desktop only):
|
||||
|
||||
| Style | Look |
|
||||
| -------------- | -------------------------------------------------------------------------------------- |
|
||||
| **Tiles** | One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
|
||||
| **Compact** | The default. Two slim pills, `WS · CPU · MEM` and the plan windows, with a small ring beside every value. Hands the tabs back the most room. |
|
||||
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
|
||||
|
||||
Hiding System Stats or Plan Usage still hides them in every style.
|
||||
|
||||
New header controls never appear on phones. Phone layout is deliberately minimal and is
|
||||
covered in [Mobile Guide](Mobile-Guide).
|
||||
|
||||
## Bottom bar
|
||||
|
||||
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
|
||||
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
|
||||
Panels → Bottom bar**. Click it for the Git window. See
|
||||
[Working With Files](Working-With-Files#git-changes).
|
||||
|
||||
## Connection state
|
||||
|
||||
The dot in the header is the quick read. Two louder surfaces exist because a cached page
|
||||
@@ -215,16 +151,11 @@ 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
|
||||
recovery stays within the bounded browser buffer.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
||||
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||
always local scrollback. OpenCode's wheel and swipes page its own conversation
|
||||
(PageUp/PageDown); in a grid tile or the split view's second pane the wheel does
|
||||
too. Other CLIs scroll locally.
|
||||
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
||||
and automatic output recovery stay within the bounded browser buffer.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
||||
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
||||
always local scrollback. Other CLIs scroll locally.
|
||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||
`Ctrl+Shift+C` always copies.
|
||||
- **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
# Tile Grid
|
||||
|
||||
Watch and drive up to six sessions at once, side by side in one window. Each tile is a
|
||||
full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether
|
||||
its agent is working, idle, or waiting on you.
|
||||
|
||||
The grid is a desktop feature. It needs a window at least about 1180px wide, and it is
|
||||
never offered in a popped-out session window.
|
||||
|
||||
## Turning it on
|
||||
|
||||
**App Settings → Header & Panels → Tiles.** This is a per-device setting, on by default
|
||||
on desktops and laptops and off on phones and tablets, and the button only appears in a
|
||||
window at least 1180px wide.
|
||||
It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift+G`.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **`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
|
||||
`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
|
||||
another tile swaps the two.
|
||||
- **"Open group as tiles"** in a tab group's menu, in the vertical tab rail with groups.
|
||||
- **Run**: a session you start from this browser tab's Run button while the grid is open
|
||||
joins it. Sessions started elsewhere (an agent, another device, a cron job) do not.
|
||||
|
||||
The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a
|
||||
2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the
|
||||
window is too small for six; the count menu says which limit applies.
|
||||
|
||||
Opening, the tiles fade in one after another and each terminal appears once its history
|
||||
has loaded, rather than scrolling through it. Closing with the button, the tiles stay
|
||||
on screen, dimmed, until the single session behind them has loaded, then fade away. With
|
||||
reduced motion turned on in your system settings, the grid opens and closes at once.
|
||||
|
||||
## A tile
|
||||
|
||||
Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
|
||||
|
||||
| Part | What it does |
|
||||
| ------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
|
||||
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
|
||||
| name | Double-click to rename the session. |
|
||||
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. OpenCode shows the model and its provider together (`Big Pickle OpenCode Zen`), exactly as its own composer does. |
|
||||
| `⋯` | The session menu: options, open in a new window, close the session. |
|
||||
| `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. |
|
||||
| `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. |
|
||||
|
||||
Click a tile to focus it. The focused tile has the accent border, takes your keyboard, and
|
||||
is the session every panel follows: files, git status, respawn and Ralph, subagent windows,
|
||||
voice and image paste. Tabs of tiled sessions carry a small underline.
|
||||
|
||||
Drag the thin lines between tiles to resize columns and rows. A tile never gets smaller than
|
||||
about 60 columns; when the window is too small for all the tiles, the grid shows the focused
|
||||
one on its own until the window is big enough again.
|
||||
|
||||
A tile whose session is not running shows **Not attached** with an **Attach** button. A tile
|
||||
whose agent exited inside its pane says so instead; close that session from `⋯`.
|
||||
|
||||
## Moving tiles
|
||||
|
||||
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
|
||||
places. Drop it on an empty slot and it moves there, leaving its old place empty; nothing else
|
||||
moves, so the empty slot can be anywhere in the grid. The dropped tile takes the focus. Press
|
||||
`Escape` or let go anywhere else and nothing changes, not even which tile has the focus: a
|
||||
header focuses its tile when you click it, not when you press it.
|
||||
|
||||
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile one place left, right, up or
|
||||
down: into the empty slot if that is the place, else trading places with the tile there. It
|
||||
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.
|
||||
|
||||
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
|
||||
tiles to five is two columns to three), the tiles keep their places if they still fit, or line
|
||||
up again from the top left. `Alt+Shift+Arrows` and `Ctrl+Tab` never stop on an empty slot.
|
||||
|
||||
## Keys
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------------ | ---------------------------------------------------------- |
|
||||
| `Ctrl+Shift+G` | Open or close the grid. |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile left, right, up or down. |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||
| `Ctrl+Tab`, `Alt+[` `]` | Cycle through the tiles. |
|
||||
| `Ctrl+L` | Clear the focused tile. |
|
||||
| `Ctrl` `+` / `Ctrl` `-` | Tile font size (tiles have their own, smaller font). |
|
||||
|
||||
All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can
|
||||
also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
|
||||
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and
|
||||
`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there;
|
||||
inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does
|
||||
not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing.
|
||||
|
||||
## Leaving the grid
|
||||
|
||||
Clicking the tab of a session that is not tiled (or picking it with `Alt+1-9` or the
|
||||
session finder) shows that session on its own, the normal single view. The grid is
|
||||
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.
|
||||
|
||||
Split shows the same logo, name and model above both of its panes.
|
||||
|
||||
The grid and Split are never open together: opening the grid turns an open split into two
|
||||
tiles, and Split is unavailable while the grid is open.
|
||||
|
||||
## Read next
|
||||
|
||||
- [The Dashboard](The-Dashboard) - the single view, tabs and the header.
|
||||
- [Keyboard Shortcuts](Keyboard-Shortcuts) - every binding.
|
||||
- [Settings Reference](Settings-Reference) - where the Tiles setting lives.
|
||||
@@ -164,11 +164,8 @@ Scrollback behaviour depends on the CLI, and Codeman adjusts what it strips per
|
||||
Things to try:
|
||||
|
||||
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
|
||||
- On Claude sessions running fullscreen (recent CLI with mouse tracking on), the wheel is
|
||||
forwarded into Claude's own transcript, so it scrolls the conversation rather than the
|
||||
terminal buffer. That is intended. Claude's default inline view scrolls locally; turn
|
||||
fullscreen on with `CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in
|
||||
`~/.claude/settings.json`.
|
||||
- On Claude sessions with a recent CLI, the wheel is forwarded into Claude's own transcript,
|
||||
so it scrolls the conversation rather than the terminal buffer. That is intended.
|
||||
- Scrolling to the very top pulls the full tmux scrollback again on demand.
|
||||
|
||||
### The wheel does nothing in a Codex session
|
||||
|
||||
@@ -22,18 +22,13 @@ transcript: what it was asked to do, what it is doing, and what it returned.
|
||||
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
||||
eight workers looks like a stalled terminal for several minutes.
|
||||
|
||||
## Session lineage lines
|
||||
## Session lineage arcs
|
||||
|
||||
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
||||
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
||||
agent through the API.
|
||||
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
|
||||
child. That covers the other direction of fan-out: not subagents inside one session, but
|
||||
whole sessions started by an agent through the API.
|
||||
|
||||
The lines form one tree per spawning tab, in that tab's colour, and run only through the
|
||||
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
|
||||
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
|
||||
dashed branch means that child is working.
|
||||
|
||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
|
||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
|
||||
for tabs scrolled out of view.
|
||||
|
||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
||||
@@ -47,8 +42,7 @@ in the CLI's own environment:
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
|
||||
```
|
||||
|
||||
and turn the per-case **Agent Teams** toggle on under **Case settings…** at the bottom of the case
|
||||
picker (the gear beside the case button on a phone).
|
||||
and turn the per-case **Agent Teams** toggle on in the case settings gear.
|
||||
|
||||
Codeman watches the team directory and matches teammates to the session leading them.
|
||||
Teammates are in-process threads rather than separate CLI processes, so they show up as
|
||||
|
||||
@@ -13,11 +13,9 @@ 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. |
|
||||
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
|
||||
| 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. |
|
||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||
| Anything else | Download. |
|
||||
|
||||
@@ -87,9 +85,8 @@ File paths in a session are links. That works in two places:
|
||||
render as underlined monospace links.
|
||||
|
||||
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
|
||||
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
|
||||
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
|
||||
viewer instead, which follows a file that is still being written.
|
||||
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
|
||||
the tail viewer instead, which follows a file that is still being written.
|
||||
|
||||
Paths **outside** the session's workspace work too, which matters because that is where most
|
||||
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
|
||||
@@ -164,44 +161,6 @@ HEIC images from an iPhone are converted to JPEG on the way in.
|
||||
When an agent produces a file the UI can show (a chart, a diagram, a document), it can
|
||||
surface as an artifact attachment rather than a path you have to go and find.
|
||||
|
||||
## Git changes
|
||||
|
||||
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
||||
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
||||
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
||||
conflicts, `? 1` a repository git could not read, `✓` when everything is committed and pushed.
|
||||
|
||||
Click it for a draggable window, in the style of the File Viewer:
|
||||
|
||||
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
|
||||
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
|
||||
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
|
||||
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
|
||||
not on remote"), which counts every commit on no remote rather than showing a green tick.
|
||||
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
|
||||
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
|
||||
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
|
||||
list of full paths instead.
|
||||
- **Click a file** to see what changed in it, as a unified diff with added and removed lines
|
||||
coloured. Staged files show index versus last commit, not-staged files show working tree
|
||||
versus index, untracked files show as all additions and deleted files as all removals.
|
||||
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
|
||||
note instead, and a diff over 400 KB is cut short.
|
||||
- A session folder that holds several projects gets one collapsible section per repository
|
||||
found up to two levels down (up to **Git status: max repositories**, 12 by default; the window says
|
||||
when there are more). A repository git could not read, typically a timeout on a slow network
|
||||
share, is listed with the reason and counted as `? N` in the bottom-bar indicator, never silently
|
||||
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
|
||||
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
|
||||
in your home folder) is ignored.
|
||||
|
||||
It is read-only and offline: Codeman never fetches, commits or changes the repository, so
|
||||
"behind" is as of your last fetch. It is not shown for Docker or remote (SSH) sessions, and a repository at or inside a Docker case
|
||||
workspace is skipped even from a local session (a container can write there, and git would run
|
||||
that repository's own configuration on the host). The
|
||||
data comes from `GET /api/sessions/:id/git-status` and `GET /api/sessions/:id/git-diff`
|
||||
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
**Using it**
|
||||
|
||||
- [The Dashboard](The-Dashboard)
|
||||
- [Tile Grid](Tile-Grid)
|
||||
- [Agent CLIs](Agent-CLIs)
|
||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||
- [Working With Files](Working-With-Files)
|
||||
|
||||
@@ -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.32.1",
|
||||
"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",
|
||||
@@ -28,7 +28,6 @@
|
||||
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
@@ -42,7 +41,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,
|
||||
@@ -105,7 +104,6 @@
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"smol-toml": "^1.9.0",
|
||||
"undici": "^6.28.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
@@ -129,8 +127,6 @@
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.0.0",
|
||||
"exceljs": "4.4.0",
|
||||
"fflate": "0.8.3",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"pngjs": "^7.0.0",
|
||||
@@ -171,7 +167,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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,79 +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.
|
||||
|
||||

|
||||
|
||||
**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
|
||||
|
||||
- 6aecc3b: ### Thanks
|
||||
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
|
||||
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
|
||||
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
|
||||
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
|
||||
|
||||
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
|
||||
|
||||
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
|
||||
|
||||
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
|
||||
|
||||
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
|
||||
|
||||
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
|
||||
|
||||
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
|
||||
|
||||
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
|
||||
|
||||
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
|
||||
|
||||
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -106,10 +106,10 @@ terminal.onData((data) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
|
||||
// Unconditional: rerender() is a no-op when there is nothing to draw, and
|
||||
// hasPending would miss an overlay that shows only an IME composition.
|
||||
terminal.onWriteParsed(() => zerolag.rerender());
|
||||
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
|
||||
terminal.onWriteParsed(() => {
|
||||
if (zerolag.hasPending) zerolag.rerender();
|
||||
});
|
||||
```
|
||||
|
||||
That is the whole integration. Everything below is for tuning it.
|
||||
@@ -186,7 +186,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
|
||||
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
||||
```
|
||||
|
||||
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,9 +202,8 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
||||
|--------|---------|-------------|
|
||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
||||
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
|
||||
### Backspace handling
|
||||
|
||||
@@ -218,19 +217,6 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|
||||
|
||||
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
||||
|
||||
### IME composition
|
||||
|
||||
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
|
||||
|
||||
```typescript
|
||||
const textarea = terminal.textarea!;
|
||||
textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
|
||||
textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
|
||||
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
|
||||
```
|
||||
|
||||
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
|
||||
|
||||
### Flushed text
|
||||
|
||||
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
||||
@@ -256,7 +242,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
|
||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||
|
||||
### Prompt
|
||||
@@ -272,8 +258,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
||||
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
|
||||
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
|
||||
| `hasPending` | `boolean` | `true` if the overlay has any content |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||
|
||||
### Options
|
||||
@@ -537,7 +522,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,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.4.1",
|
||||
"version": "0.3.1",
|
||||
"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",
|
||||
|
||||
@@ -58,7 +58,6 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const {
|
||||
lines,
|
||||
compositionStart,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
@@ -91,24 +90,12 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||
// dropped along with that line when the tail is all that fits.
|
||||
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||
// Code-point offset of each line in the whole text, so the composition
|
||||
// styling survives the tail slice below.
|
||||
const lineOffsets: number[] = [];
|
||||
{
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
lineOffsets.push(offset);
|
||||
offset += [...line].length;
|
||||
}
|
||||
}
|
||||
let visibleLines = lines;
|
||||
let firstVisible = 0;
|
||||
let keepsPromptLine = true;
|
||||
let topRow = promptRow;
|
||||
if (rows && rows > 0) {
|
||||
if (lines.length > rows) {
|
||||
firstVisible = lines.length - rows;
|
||||
visibleLines = lines.slice(firstVisible);
|
||||
visibleLines = lines.slice(lines.length - rows);
|
||||
keepsPromptLine = false;
|
||||
topRow = 0;
|
||||
} else if (promptRow + lines.length > rows) {
|
||||
@@ -129,21 +116,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineCompositionFrom =
|
||||
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||
const lineEl = makeLine(
|
||||
visibleLines[i],
|
||||
leftPx,
|
||||
topPx,
|
||||
widthPx,
|
||||
cellH,
|
||||
cellW,
|
||||
charTop,
|
||||
charHeight,
|
||||
font,
|
||||
terminal,
|
||||
lineCompositionFrom
|
||||
);
|
||||
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
@@ -171,10 +144,7 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* Each character gets its own `<span>` positioned by visual column offset.
|
||||
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||
* composition text: underlined, like xterm's own composition view, and marked
|
||||
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
* CJK wide characters occupy 2 cell widths.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
@@ -186,8 +156,7 @@ function makeLine(
|
||||
_charTop: number,
|
||||
_charHeight: number,
|
||||
font: FontStyle,
|
||||
terminal?: XtermTerminal | null,
|
||||
compositionFrom?: number
|
||||
terminal?: XtermTerminal | null
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
@@ -203,7 +172,6 @@ function makeLine(
|
||||
|
||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||
let colOffset = 0;
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
const cw = charCellWidth(terminal, ch);
|
||||
const span = document.createElement('span');
|
||||
@@ -221,15 +189,9 @@ function makeLine(
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||
span.style.textDecoration = 'underline';
|
||||
span.setAttribute('data-zerolag-composition', '');
|
||||
span.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
span.textContent = ch;
|
||||
el.appendChild(span);
|
||||
colOffset += cw;
|
||||
index++;
|
||||
}
|
||||
|
||||
return el;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,12 +163,6 @@ export interface CellDimensions {
|
||||
/** Parameters for the overlay renderer. */
|
||||
export interface RenderParams {
|
||||
lines: string[];
|
||||
/**
|
||||
* Index (in code points, across all `lines`) where IME composition text
|
||||
* begins. Characters from there on are drawn underlined and marked
|
||||
* `data-zerolag-composition`. Omit when nothing is being composed.
|
||||
*/
|
||||
compositionStart?: number;
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
|
||||
@@ -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 };
|
||||
@@ -67,8 +67,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
private _flushedOffset = 0;
|
||||
private _flushedText = '';
|
||||
private _bufferDetectDone = false;
|
||||
// IME text still being composed: drawn after the pending text, never sent.
|
||||
private _composition = '';
|
||||
|
||||
// Render cache
|
||||
private _lastRenderKey = '';
|
||||
@@ -122,16 +120,17 @@ 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);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
} else if (this._hasContent()) {
|
||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = setTimeout(() => {
|
||||
this._scrollTimer = null;
|
||||
@@ -207,14 +206,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* - `'flushed'`: A character was removed from text already sent to the PTY.
|
||||
* The consumer SHOULD send backspace to the PTY.
|
||||
* - `false`: Nothing to remove. The consumer should NOT send backspace.
|
||||
*
|
||||
* Any IME composition is dropped in every case, and the overlay is repainted
|
||||
* without it (hidden when nothing else is left).
|
||||
*/
|
||||
removeChar(): 'pending' | 'flushed' | false {
|
||||
// A backspace that reaches the overlay means no composition is open.
|
||||
const droppedComposition = this._composition.length > 0;
|
||||
this._composition = '';
|
||||
if (this._pendingText.length > 0) {
|
||||
this._pendingText = this._pendingText.slice(0, -1);
|
||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||
@@ -250,9 +243,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
return 'flushed';
|
||||
}
|
||||
|
||||
// Nothing to remove, but a composition-only overlay is still on screen
|
||||
// drawing the text dropped above.
|
||||
if (droppedComposition) this._hide();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -262,7 +252,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
*/
|
||||
clear(): void {
|
||||
this._pendingText = '';
|
||||
this._composition = '';
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
this._bufferDetectDone = false;
|
||||
@@ -308,7 +297,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
clearFlushed(): void {
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
if (this._pendingText || this._composition) {
|
||||
if (this._pendingText) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
@@ -323,7 +312,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
* that move the prompt.
|
||||
*/
|
||||
rerender(): void {
|
||||
if (this._hasContent()) {
|
||||
if (this._pendingText || this._flushedOffset > 0) {
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}
|
||||
@@ -336,7 +325,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
refreshFont(): void {
|
||||
this._cacheFont();
|
||||
this._lastRenderKey = '';
|
||||
if (this._hasContent()) this._render();
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
}
|
||||
|
||||
// ─── Buffer detection ─────────────────────────────────────────────
|
||||
@@ -402,37 +391,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
this._options.prompt = finder;
|
||||
this._lastPromptPos = null;
|
||||
this._lastRenderKey = '';
|
||||
if (this._hasContent()) this._render();
|
||||
}
|
||||
|
||||
// ─── IME composition ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Show text an IME is still composing as an underlined tail after the
|
||||
* pending text, wrapped and kept on screen like the rest of the overlay.
|
||||
* Pass `''` to remove it.
|
||||
*
|
||||
* Visual only: the composition is never part of `pendingText`, `hasPending`
|
||||
* or anything a consumer sends. When the IME commits, the consumer adds the
|
||||
* committed text the usual way (`addChar`/`appendText`) and clears the
|
||||
* composition. `clear()` and `removeChar()` drop it too.
|
||||
*/
|
||||
setComposition(text: string): void {
|
||||
// One visual line of provisional text: control characters and line breaks
|
||||
// would break the cell grid.
|
||||
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
|
||||
if (next === this._composition) return;
|
||||
this._composition = next;
|
||||
if (this._hasContent()) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
|
||||
get composition(): string {
|
||||
return this._composition;
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
}
|
||||
|
||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||
@@ -466,13 +425,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
return this._pendingText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is pending or flushed text. Excludes the IME composition,
|
||||
* which is never sent, so an overlay showing only a composition reports
|
||||
* `false` while still on screen. To re-place the overlay after output or a
|
||||
* resize, call `rerender()` unconditionally: it is a no-op when there is
|
||||
* nothing to draw.
|
||||
*/
|
||||
/** Whether there is any overlay content (pending or flushed). */
|
||||
get hasPending(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||
}
|
||||
@@ -490,10 +443,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
// ─── Private methods ──────────────────────────────────────────────
|
||||
|
||||
private _hasContent(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
|
||||
}
|
||||
|
||||
private _getPromptOffset(): number {
|
||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||
return prompt.offset ?? 2;
|
||||
@@ -556,7 +505,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
private _render(): void {
|
||||
if (!this._terminal || !this._overlay) return;
|
||||
if (!this._hasContent()) {
|
||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -564,8 +513,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;
|
||||
}
|
||||
@@ -614,16 +563,12 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
}
|
||||
}
|
||||
|
||||
// The composition is a styled tail after everything the user has typed.
|
||||
const compositionStart = [...displayText].length;
|
||||
displayText += this._composition;
|
||||
|
||||
// Skip redundant re-renders — include text content to detect
|
||||
// same-length changes (e.g., setFlushed with different text)
|
||||
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||
// changing the text — must not be skipped as a redundant render.
|
||||
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
@@ -663,7 +608,6 @@ export class ZerolagInputAddon implements XtermAddon {
|
||||
|
||||
renderOverlay(this._overlay, {
|
||||
lines,
|
||||
compositionStart: this._composition ? compositionStart : undefined,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// setComposition(): IME text still being composed, drawn as an underlined tail
|
||||
// after the pending text. Visual only, never part of what a consumer sends.
|
||||
|
||||
const CELL_W = 10;
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: opts.lines ?? ['$ '] },
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
cellWidth: CELL_W,
|
||||
cellHeight: 20,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
|
||||
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
|
||||
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||
}
|
||||
|
||||
function lineText(line: HTMLDivElement): string {
|
||||
return Array.from(line.children)
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function compositionText(overlay: HTMLDivElement): string {
|
||||
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('setComposition', () => {
|
||||
it('renders the composition after pendingText, underlined and aria-hidden', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
|
||||
const [line] = lineDivs(overlay);
|
||||
expect(lineText(line)).toBe('abcxy');
|
||||
const spans = Array.from(line.children) as HTMLSpanElement[];
|
||||
for (const span of spans.slice(0, 3)) {
|
||||
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
|
||||
expect(span.style.textDecoration).toBe('');
|
||||
}
|
||||
for (const span of spans.slice(3)) {
|
||||
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
|
||||
expect(span.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(span.style.textDecoration).toBe('underline');
|
||||
}
|
||||
// Grid positions continue straight on from the pending text.
|
||||
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
|
||||
expect(overlay.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('places a wide composition by cell width after wide pending text', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('今日は');
|
||||
addon.setComposition('天気');
|
||||
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||||
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||||
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
|
||||
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
|
||||
});
|
||||
|
||||
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
|
||||
const { addon } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setFlushed(2, 'zz');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
|
||||
expect(addon.composition).toBe('xy');
|
||||
expect(addon.state.pendingText).toBe('abc');
|
||||
expect(addon.state.flushedText).toBe('zz');
|
||||
});
|
||||
|
||||
it('shows on an empty prompt without making anything pending', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('かな');
|
||||
expect(addon.pendingText).toBe('');
|
||||
expect(addon.hasPending).toBe(false);
|
||||
expect(addon.state.visible).toBe(true);
|
||||
expect(compositionText(overlay)).toBe('かな');
|
||||
});
|
||||
|
||||
it('wraps with the pending text: the tail continues onto the next line', () => {
|
||||
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
|
||||
const { addon, overlay } = setup({ cols: 12 });
|
||||
addon.appendText('abcdefgh');
|
||||
addon.setComposition('WXYZ');
|
||||
const lines = lineDivs(overlay);
|
||||
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
|
||||
expect(compositionText(overlay)).toBe('WXYZ');
|
||||
const second = Array.from(lines[1].children) as HTMLSpanElement[];
|
||||
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
|
||||
expect(second[0].style.left).toBe('0px');
|
||||
});
|
||||
|
||||
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
|
||||
// 2 visible rows, 3 lines of text: the first line is dropped.
|
||||
const { addon, overlay } = setup({ cols: 6, rows: 2 });
|
||||
addon.appendText('abcdefghij');
|
||||
addon.setComposition('XYZ');
|
||||
const lines = lineDivs(overlay);
|
||||
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
|
||||
expect(compositionText(overlay)).toBe('XYZ');
|
||||
const first = Array.from(lines[0].children);
|
||||
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
|
||||
});
|
||||
|
||||
it("setComposition('') removes the tail and keeps the pending text", () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.setComposition('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(addon.pendingText).toBe('abc');
|
||||
});
|
||||
|
||||
it("setComposition('') on an otherwise empty overlay hides it", () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('xy');
|
||||
addon.setComposition('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
expect(overlay.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.clear();
|
||||
expect(addon.composition).toBe('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
addon.addChar('q');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('q');
|
||||
});
|
||||
|
||||
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.removeChar()).toBe('pending');
|
||||
expect(addon.pendingText).toBe('ab');
|
||||
expect(addon.composition).toBe('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||
});
|
||||
|
||||
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('ka');
|
||||
expect(compositionText(overlay)).toBe('ka');
|
||||
expect(addon.removeChar()).toBe(false);
|
||||
expect(addon.composition).toBe('');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(overlay.style.display).toBe('none');
|
||||
expect(addon.state.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('removeChar() repaints flushed text without the dropped composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setFlushed(3, 'abc');
|
||||
addon.setComposition('xy');
|
||||
expect(addon.removeChar()).toBe('flushed');
|
||||
expect(compositionText(overlay)).toBe('');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
|
||||
});
|
||||
|
||||
it('text appended while composing lands before the tail', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('ab');
|
||||
addon.setComposition('xy');
|
||||
addon.addChar('c');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
});
|
||||
|
||||
it('rerender() and refreshFont() keep the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('xy');
|
||||
addon.rerender();
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
addon.refreshFont();
|
||||
expect(compositionText(overlay)).toBe('xy');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
});
|
||||
|
||||
it('re-renders when only the composition changes', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('abc');
|
||||
addon.setComposition('x');
|
||||
addon.setComposition('xy');
|
||||
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
|
||||
});
|
||||
|
||||
it('strips control characters and line breaks from the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.setComposition('a\nb\u0007c
');
|
||||
expect(addon.composition).toBe('abc');
|
||||
expect(compositionText(overlay)).toBe('abc');
|
||||
});
|
||||
|
||||
it('draws the block cursor after the composition', () => {
|
||||
const { addon, overlay } = setup();
|
||||
addon.appendText('ab');
|
||||
addon.setComposition('xy');
|
||||
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||||
// prompt col 0 + offset 2 + 4 cells
|
||||
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
|
||||
});
|
||||
});
|
||||
@@ -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,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.32.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -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
|
||||
@@ -53,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -81,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -202,11 +196,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
@@ -218,19 +207,12 @@ spawn_worker() {
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -390,10 +372,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -444,7 +426,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
@@ -511,12 +493,6 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||
stronger model it consults before committing to an approach, on a recurring error and
|
||||
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||
`fable` do anything).
|
||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -118,11 +118,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
@@ -134,19 +129,12 @@ spawn_worker() {
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -306,4 +294,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
|
||||
@@ -345,13 +345,6 @@ ESC=$(printf '\033')
|
||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||
|
||||
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||
`CODEMAN_WORKER_ADVISOR` is set.
|
||||
|
||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||
@@ -391,18 +384,17 @@ every claude create path installs them, so a linked case and a raw path both get
|
||||
|
||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
`envOverrides`). Three differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
(`session-routes.ts:648`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -95,9 +95,6 @@ Differences from `quick-start` worth knowing before you debug one:
|
||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||
one that does not answer or cannot be read (an unreachable network mount, a
|
||||
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||
create a replacement for it;
|
||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||
`SESSION_BUSY` for the identical condition.
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* Extracted from the package.json one-liner for readability and debuggability.
|
||||
*
|
||||
* Steps:
|
||||
* 0. Preflight: the build-time packages resolve (nothing is touched before it)
|
||||
* 1. TypeScript compilation
|
||||
* 2. Copy static assets (web/public, templates)
|
||||
* 3. Build vendor xterm bundles
|
||||
@@ -14,7 +13,6 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -27,31 +25,6 @@ function run(label, cmd) {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
|
||||
}
|
||||
|
||||
// 0. Preflight: resolve the build-time packages the asset stage reads only AFTER it has
|
||||
// deleted dist/web/public (step 2), before anything is touched. A tree whose node_modules
|
||||
// predate them (a deploy that pulled but never ran `npm install`) used to fail mid-build
|
||||
// with dist/web/public already wiped, so the running server kept serving an index.html
|
||||
// whose hashed assets were gone. Keep the list in step with every require.resolve in
|
||||
// scripts/prepare-spreadsheet-assets.mjs (test/spreadsheet-assets.test.ts checks it).
|
||||
// Only specifiers that resolve without an exports map in the way: a subpath of a package
|
||||
// that has one (@xterm/*) can throw ERR_PACKAGE_PATH_NOT_EXPORTED while installed.
|
||||
// A hand-run of the asset stage alone (past a blocked tsc) skips this check.
|
||||
const BUILD_TIME_MODULES = ['exceljs/dist/exceljs.min.js', 'fflate'];
|
||||
const requireFromBuild = createRequire(import.meta.url);
|
||||
const missingModules = BUILD_TIME_MODULES.filter((specifier) => {
|
||||
try {
|
||||
requireFromBuild.resolve(specifier);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (missingModules.length > 0) {
|
||||
console.error(`[build] missing build dependency: ${missingModules.join(', ')}`);
|
||||
console.error('[build] run `npm install` first, then `npm run build` again. Nothing was built or deleted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. TypeScript compilation
|
||||
run('tsc', 'tsc');
|
||||
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||
@@ -76,9 +49,6 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li
|
||||
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
||||
// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js,
|
||||
// never by the page (see scripts/prepare-spreadsheet-assets.mjs).
|
||||
run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
|
||||
|
||||
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
||||
appendFileSync(
|
||||
@@ -113,11 +83,9 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
|
||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
|
||||
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
||||
@@ -143,10 +111,8 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'mobile-ime-preview.js',
|
||||
'terminal-keycode229-recovery.js',
|
||||
'sanitize-html.js',
|
||||
'tab-layout-browser.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
'terminal-ui.js',
|
||||
@@ -159,7 +125,6 @@ console.log('\n[build] content-hash cache busting');
|
||||
'api-client.js',
|
||||
'subagent-windows.js',
|
||||
'image-input.js',
|
||||
'spreadsheet-preview.js',
|
||||
'vendor/xterm-zerolag-input.js',
|
||||
'vendor/xterm-predictive-echo.js',
|
||||
];
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser-test exclusion check.
|
||||
*
|
||||
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
|
||||
* clean checkout) have no chromium, so such a file dies with
|
||||
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
|
||||
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
|
||||
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
|
||||
* BY HAND, and a new browser test simply does not appear in it unless someone
|
||||
* remembers. The omission is invisible on a developer machine that has run
|
||||
* `npx playwright install`, where the test passes, and only shows up on a clean
|
||||
* runner.
|
||||
*
|
||||
* Two deliberate design choices:
|
||||
*
|
||||
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
|
||||
* would miss the browser tests that predate that convention
|
||||
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
|
||||
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
|
||||
* file dangerous is importing a browser driver, so that is what is tested.
|
||||
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
|
||||
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
|
||||
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
|
||||
*
|
||||
* 2. **The exclusion side is answered by vitest itself**, via
|
||||
* `vitest list --filesOnly`, rather than by re-implementing glob matching
|
||||
* against the config's `exclude` array. Patterns there include `test/mobile/**`
|
||||
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
|
||||
* edge case would report a gap that does not exist, or miss one that does.
|
||||
* Asking the real resolver cannot drift from the real behaviour.
|
||||
*
|
||||
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
|
||||
* check itself only runs when this file is executed directly.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, relative, sep, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
|
||||
const SUITES_FILE = join('config', 'test-suites.ts');
|
||||
|
||||
/** Importing any one of these means the test needs a real browser binary. */
|
||||
const BROWSER_DRIVER =
|
||||
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
|
||||
|
||||
/** @param {string} source */
|
||||
export function importsBrowserDriver(source) {
|
||||
return BROWSER_DRIVER.test(source);
|
||||
}
|
||||
|
||||
/** @param {string} dir @returns {string[]} */
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(path));
|
||||
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `*.test.ts` under `<root>/test`, as sorted repo-relative POSIX paths (the form
|
||||
* `vitest list` prints).
|
||||
*
|
||||
* @param {string} root
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findTestFiles(root) {
|
||||
return walk(join(root, 'test'))
|
||||
.map((file) => relative(root, file).split(sep).join('/'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of {@link findTestFiles} that imports a browser driver.
|
||||
*
|
||||
* @param {string} root
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findBrowserTests(root) {
|
||||
return findTestFiles(root).filter((file) => importsBrowserDriver(readFileSync(join(root, file), 'utf8')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
|
||||
* blank or decorative lines are ignored rather than assuming the format is pristine.
|
||||
*
|
||||
* @param {string} output
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function parseVitestFileList(output) {
|
||||
return new Set(
|
||||
output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.endsWith('.test.ts'))
|
||||
.map((line) => line.replace(/^\.\//, ''))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `vitest list` paths and the walked tree name at least one file in common.
|
||||
* False means the two sides are not speaking the same path format (absolute paths, backslashes
|
||||
* or a new prefix after a vitest upgrade), and then {@link findLeaks} would find nothing
|
||||
* against a perfectly non-empty listing.
|
||||
*
|
||||
* @param {Set<string>} ciFiles
|
||||
* @param {string[]} testFiles
|
||||
*/
|
||||
export function listingMatchesTree(ciFiles, testFiles) {
|
||||
return testFiles.some((file) => ciFiles.has(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} browserTests
|
||||
* @param {Set<string>} ciFiles
|
||||
* @returns {string[]} browser-driven files that the CI config would still collect
|
||||
*/
|
||||
export function findLeaks(browserTests, ciFiles) {
|
||||
return browserTests.filter((file) => ciFiles.has(file));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const testFiles = findTestFiles(ROOT);
|
||||
const browserTests = findBrowserTests(ROOT);
|
||||
|
||||
let collected;
|
||||
try {
|
||||
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('✗ could not enumerate the CI test set via `vitest list`.');
|
||||
console.error(err.stderr ? err.stderr.toString() : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciFiles = parseVitestFileList(collected);
|
||||
if (ciFiles.size === 0) {
|
||||
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
|
||||
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
|
||||
process.exit(1);
|
||||
}
|
||||
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
|
||||
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
|
||||
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
|
||||
if (!listingMatchesTree(ciFiles, testFiles)) {
|
||||
const sample = [...ciFiles].slice(0, 3).join(', ');
|
||||
console.error(
|
||||
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const leaked = findLeaks(browserTests, ciFiles);
|
||||
|
||||
if (leaked.length > 0) {
|
||||
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
|
||||
for (const file of leaked) console.error(` ${file}`);
|
||||
console.error(`
|
||||
These import a browser driver, so on a runner with no chromium they fail with
|
||||
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
|
||||
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
|
||||
it, and \`npm run test:browser\` its includes).
|
||||
|
||||
They may well pass on this machine; that is the trap. To reproduce a clean
|
||||
runner locally:
|
||||
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, extname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -10,10 +9,6 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const publicRoot = resolve(repoRoot, 'src/web/public');
|
||||
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
||||
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
||||
// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
|
||||
// They load only inside the spreadsheet worker, but a dependency bump that
|
||||
// balloons them should be a deliberate decision, not a silent one.
|
||||
const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
|
||||
|
||||
function collectTextAssets(dir) {
|
||||
const files = [];
|
||||
@@ -40,39 +35,6 @@ function findNullByte(buffer) {
|
||||
const files = collectTextAssets(publicRoot);
|
||||
const failures = [];
|
||||
|
||||
// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
|
||||
// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
|
||||
// the content hash of everything the worker loads, or a deploy can pair a new
|
||||
// worker with a stale cached core/vendor file (static assets are cached 1y).
|
||||
const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
|
||||
const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
|
||||
const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
|
||||
const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
|
||||
|
||||
if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
|
||||
const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
|
||||
if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
|
||||
failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
|
||||
}
|
||||
|
||||
const expectedVersion = createHash('sha256')
|
||||
.update(readFileSync(spreadsheetWorker))
|
||||
.update(readFileSync(spreadsheetCore))
|
||||
.update(readFileSync(spreadsheetVendors[0]))
|
||||
.update(readFileSync(spreadsheetVendors[1]))
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
const entrySource = readFileSync(spreadsheetEntry, 'utf8');
|
||||
const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
|
||||
if (actualVersion !== expectedVersion) {
|
||||
failures.push(
|
||||
`SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const rel = relative(repoRoot, file);
|
||||
const data = readFileSync(file);
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
|
||||
* pinned by test/git-hooks.test.ts.
|
||||
*
|
||||
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
|
||||
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
|
||||
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
|
||||
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
|
||||
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
|
||||
*
|
||||
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
|
||||
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
|
||||
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
|
||||
* within a day. The checks below mirror the static CI job.
|
||||
*
|
||||
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
|
||||
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
|
||||
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
|
||||
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
|
||||
* the second case is usually another session's WIP, which must not block this push.
|
||||
*
|
||||
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
|
||||
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
|
||||
* survive `npm install`.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Ownership marker. ⚠️ Never bump the version suffix: ownership is matched on this exact
|
||||
* string, so a `v2` would read every installed `v1` hook as foreign and never refresh it.
|
||||
* A changed body still reaches installed hooks, because the refresh compares the whole file.
|
||||
*/
|
||||
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
|
||||
|
||||
/**
|
||||
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
|
||||
* is the argument list for `npm run`, and each is a step of the static job in
|
||||
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
|
||||
*/
|
||||
export const PRE_PUSH_CHECKS = [
|
||||
['check:lockfile'],
|
||||
['generate:cli-catalog', '--', '--check'],
|
||||
['check:browser-excludes'],
|
||||
['check:frontend-syntax'],
|
||||
['format:check'],
|
||||
['lint'],
|
||||
['typecheck'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
|
||||
* Derived from what each check reads: src/ (format:check, lint, typecheck,
|
||||
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
|
||||
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
|
||||
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
|
||||
* package.json + package-lock.json (check:lockfile), install.sh (generate:cli-catalog
|
||||
* --check diffs its generated block), tsconfig.json (typecheck, and
|
||||
* config/tsconfig.scripts.json extends it) and .prettierignore + .editorconfig
|
||||
* (format:check; the Prettier CLI honours .editorconfig by default).
|
||||
*/
|
||||
export const PRE_PUSH_WATCHED_PATHS = [
|
||||
'src',
|
||||
'config',
|
||||
'scripts',
|
||||
'test',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'install.sh',
|
||||
'tsconfig.json',
|
||||
'.prettierignore',
|
||||
'.editorconfig',
|
||||
];
|
||||
|
||||
/**
|
||||
* Render the pre-push hook script.
|
||||
*
|
||||
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
|
||||
*/
|
||||
export function renderPrePushHook() {
|
||||
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
|
||||
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
|
||||
|
||||
return `#!/bin/sh
|
||||
${PRE_PUSH_MARKER}
|
||||
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
|
||||
# it is regenerated on npm install. Delete the marker line above to take ownership
|
||||
# and the installer will leave your version alone.
|
||||
#
|
||||
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
|
||||
# Skip always: remove this file.
|
||||
|
||||
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
cd "$repo_root" || exit 0
|
||||
|
||||
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
|
||||
# npm install). Warn rather than blocking the push on a setup detail.
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# GUI git clients and IDEs often run hooks with a minimal PATH that lacks an nvm or
|
||||
# Homebrew Node. Every check would then fail with "npm: not found", so skip instead.
|
||||
command -v npm >/dev/null 2>&1 || { echo "pre-push: npm not on PATH, skipping checks."; exit 0; }
|
||||
|
||||
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
|
||||
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
|
||||
# The checks below read the working tree, so they only say something about a pushed commit
|
||||
# that IS the checked-out HEAD (tags are peeled to their commit first).
|
||||
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
|
||||
has_content=0
|
||||
not_head=''
|
||||
while read -r localref localsha _remoteref _remotesha; do
|
||||
[ -z "$localsha" ] && continue
|
||||
case "$localsha" in
|
||||
0000000000000000000000000000000000000000) ;;
|
||||
*)
|
||||
has_content=1
|
||||
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
|
||||
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ "$has_content" = "0" ] && exit 0
|
||||
|
||||
if [ -n "$not_head" ]; then
|
||||
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
|
||||
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
|
||||
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
|
||||
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
|
||||
trap 'rm -f "$log"' EXIT
|
||||
|
||||
failed=''
|
||||
run_check() {
|
||||
if ! npm run --silent "$@" >"$log" 2>&1; then
|
||||
echo ""
|
||||
echo "pre-push: FAILED npm run $*"
|
||||
tail -n 25 "$log"
|
||||
failed="$failed $1"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "pre-push: running static checks (~10-40s)..."
|
||||
${runs}
|
||||
|
||||
if [ -n "$failed" ]; then
|
||||
echo ""
|
||||
echo "pre-push: blocked by:$failed"
|
||||
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push: static checks passed."
|
||||
exit 0
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with an existing hook file.
|
||||
*
|
||||
* @param {{ existing: string | null | undefined, next: string }} args
|
||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||
*/
|
||||
export function planHookInstall({ existing, next }) {
|
||||
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
|
||||
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
|
||||
return existing === next ? 'up-to-date' : 'write';
|
||||
}
|
||||
|
||||
/** @param {string} cwd @param {string[]} args */
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
|
||||
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
|
||||
*
|
||||
* @param {string} path
|
||||
*/
|
||||
function canonicalPath(path) {
|
||||
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
|
||||
* is nothing to install into.
|
||||
*
|
||||
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
|
||||
* `.git` is a FILE pointing at the parent repo, so the hooks live under
|
||||
* `--git-common-dir`.
|
||||
*
|
||||
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
|
||||
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
|
||||
* shared hooks directory used by every repo on the machine); installing there would
|
||||
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
|
||||
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
|
||||
* the comparison is on canonical paths rather than on whether the setting exists.
|
||||
*
|
||||
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
|
||||
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
|
||||
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolveGitHooksDir(repoRoot) {
|
||||
try {
|
||||
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
|
||||
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
|
||||
// Both are printed relative to the cwd (repoRoot) unless already absolute.
|
||||
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
|
||||
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
|
||||
if (!hooks || !common) return null;
|
||||
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
|
||||
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
|
||||
* {@link planHookInstall}: a hook without the marker is never touched.
|
||||
*
|
||||
* @param {string} hooksDir
|
||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||
*/
|
||||
export function installPrePushHook(hooksDir) {
|
||||
const path = join(hooksDir, 'pre-push');
|
||||
const next = renderPrePushHook();
|
||||
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
||||
const action = planHookInstall({ existing, next });
|
||||
if (action === 'write') {
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
writeFileSync(path, next, { mode: 0o755 });
|
||||
chmodSync(path, 0o755); // `mode` only applies when the file is created
|
||||
}
|
||||
return action;
|
||||
}
|
||||
@@ -64,15 +64,6 @@ export const GIT_HOST_CLI_BUILD_ARGS = [
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Environment variable → Dockerfile ARG for the image's system Git identity.
|
||||
* ⚠️ Mirrored by `GIT_IDENTITY_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
|
||||
*/
|
||||
export const GIT_IDENTITY_BUILD_ARGS = [
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||
@@ -91,25 +82,9 @@ export function gitHostCliBuildArgPairs(env) {
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs for Git identity, requiring either both values or neither. */
|
||||
export function gitIdentityBuildArgPairs(env) {
|
||||
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']);
|
||||
const configured = pairs.filter(([, value]) => value !== '');
|
||||
if (configured.length === 0) return [];
|
||||
if (configured.length !== pairs.length) {
|
||||
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
|
||||
throw new Error(`${names} must both be set when configuring Git identity`);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||
export function agentImageBuildArgPairs(catalog, env = process.env) {
|
||||
return [
|
||||
['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')],
|
||||
...gitHostCliBuildArgPairs(env),
|
||||
...gitIdentityBuildArgPairs(env),
|
||||
];
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||
}
|
||||
|
||||
/** Read the committed catalogue. IO. */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -341,22 +341,6 @@ if (isGlobalInstall) {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into
|
||||
// src/web/public/vendor/ for dev mode. The build does the same into dist/.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (!isGlobalInstall) {
|
||||
try {
|
||||
execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' });
|
||||
console.log(colors.green('✓ Spreadsheet preview vendor files prepared'));
|
||||
} catch (err) {
|
||||
hasWarnings = true;
|
||||
console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files'));
|
||||
console.log(colors.dim(` ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
||||
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
||||
@@ -372,17 +356,14 @@ if (!isGlobalInstall) {
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 5. Install git hooks (pre-commit format check, pre-push static checks)
|
||||
// 5. Install git pre-commit hook (format check)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (!isGlobalInstall) {
|
||||
try {
|
||||
const { writeFileSync, mkdirSync } = await import('fs');
|
||||
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
|
||||
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
|
||||
// null when this directory is not the top of a git checkout.
|
||||
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
|
||||
if (gitHooksDir) {
|
||||
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
|
||||
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
|
||||
mkdirSync(gitHooksDir, { recursive: true });
|
||||
const hook = `#!/bin/bash
|
||||
# Auto-installed by postinstall — prevents CI format failures
|
||||
@@ -398,18 +379,9 @@ fi
|
||||
const hookPath = join(gitHooksDir, 'pre-commit');
|
||||
writeFileSync(hookPath, hook, { mode: 0o755 });
|
||||
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
||||
|
||||
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
|
||||
// hook the developer wrote themselves is left alone.
|
||||
const action = installPrePushHook(gitHooksDir);
|
||||
if (action === 'write') {
|
||||
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
|
||||
} else if (action === 'skip-foreign') {
|
||||
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — git hooks are a convenience
|
||||
// Non-critical — git hook is a convenience
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor
|
||||
* dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by
|
||||
* build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly
|
||||
* in package.json, and check-public-assets.mjs hashes the output into
|
||||
* SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump
|
||||
* that changes the bytes fails that check until the token is refreshed.
|
||||
* Source-map comments are stripped: the maps are not shipped.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor'));
|
||||
const excelSource = require.resolve('exceljs/dist/exceljs.min.js');
|
||||
const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js');
|
||||
|
||||
function copyBrowserBundle(source, outputName) {
|
||||
const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n');
|
||||
if (/sourceMappingURL/.test(content)) {
|
||||
throw new Error(`Failed to strip sourceMappingURL from ${outputName}`);
|
||||
}
|
||||
writeFileSync(join(outputDir, outputName), content, 'utf8');
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
copyBrowserBundle(excelSource, 'exceljs.min.js');
|
||||
copyBrowserBundle(fflateSource, 'fflate.min.js');
|
||||
@@ -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
|
||||
@@ -53,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
```
|
||||
|
||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||
@@ -81,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -202,11 +196,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
@@ -218,19 +207,12 @@ spawn_worker() {
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -390,10 +372,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
```
|
||||
|
||||
Every later Bash call that touches the API starts with the same two loader lines from
|
||||
@@ -444,7 +426,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
T=('reply with one line: the absolute path of your working directory'
|
||||
@@ -511,12 +493,6 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
|
||||
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
|
||||
stronger model it consults before committing to an approach, on a recurring error and
|
||||
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
|
||||
below the worker's own model is never attached (on an Opus worker only `opus` and
|
||||
`fable` do anything).
|
||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||
@@ -118,11 +118,6 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
||||
# rather than handed back, because a worker that never drew its composer would eat the
|
||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
||||
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
|
||||
@@ -134,19 +129,12 @@ spawn_worker() {
|
||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
|
||||
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||
# A server without advisor support DROPS the field instead of refusing it, so read it
|
||||
# back: a worker silently missing the advisor it was asked for is worth one line.
|
||||
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
|
||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
|
||||
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
|
||||
@@ -306,4 +294,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.33.4
|
||||
CODEMAN_PREAMBLE=1.30.1
|
||||
|
||||
@@ -345,13 +345,6 @@ ESC=$(printf '\033')
|
||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||
|
||||
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
|
||||
model id): Claude Code's advisor tool, a stronger model the worker consults before
|
||||
committing to an approach, on a recurring error and before declaring the task done. It is
|
||||
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
|
||||
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
|
||||
`CODEMAN_WORKER_ADVISOR` is set.
|
||||
|
||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||
@@ -391,18 +384,17 @@ every claude create path installs them, so a linked case and a raw path both get
|
||||
|
||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
||||
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
|
||||
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
|
||||
differences that break copied code:
|
||||
`envOverrides`). Three differences that break copied code:
|
||||
|
||||
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
|
||||
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
|
||||
(`session-routes.ts:878` returns `{ session: lightState }`).
|
||||
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
|
||||
`wait?until=exit` answers `exit` immediately. Follow it with
|
||||
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
|
||||
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
|
||||
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
|
||||
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
|
||||
(`sessionCapacityMessage()` in `route-helpers.ts`).
|
||||
(`session-routes.ts:648`).
|
||||
|
||||
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
|
||||
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||
```
|
||||
|
||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||
|
||||
@@ -95,9 +95,6 @@ Differences from `quick-start` worth knowing before you debug one:
|
||||
- the id is at `.data.session.id`, not `.data.sessionId`;
|
||||
- `workingDir` must already exist (400 `INVALID_INPUT`, "workingDir does not exist"),
|
||||
and in multi-user mode must be inside the caller's own workspace (403 `FORBIDDEN`);
|
||||
one that does not answer or cannot be read (an unreachable network mount, a
|
||||
permission error) is 422 `OPERATION_FAILED`, never "does not exist", so do not
|
||||
create a replacement for it;
|
||||
- hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns
|
||||
`SESSION_BUSY` for the identical condition.
|
||||
|
||||
|
||||
@@ -53,20 +53,15 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
*/
|
||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
||||
|
||||
/**
|
||||
* Document types an attachment card previews. Also the list `codeman attach`'s
|
||||
* error text names, so the help cannot drift from what is accepted. `xlsx` is
|
||||
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
|
||||
*/
|
||||
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
|
||||
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||
'pdf',
|
||||
'docx',
|
||||
'pptx',
|
||||
'md',
|
||||
'txt',
|
||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
@@ -159,7 +154,6 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
|
||||
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
|
||||
if (normalized === 'pdf') return 'pdf';
|
||||
if (normalized === 'pptx') return 'presentation';
|
||||
if (normalized === 'xlsx') return 'spreadsheet';
|
||||
if (normalized === 'md') return 'markdown';
|
||||
// Everything else in the text family reads as text, including code and
|
||||
// config: the card and the preview both treat it as a plain-text 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;
|
||||
}
|
||||
@@ -15,17 +15,15 @@ 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';
|
||||
import { getStore } from './state-store.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.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;
|
||||
@@ -89,11 +111,7 @@ program
|
||||
.action(async (filePath, options) => {
|
||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||
console.error(
|
||||
palette.err(
|
||||
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
|
||||
)
|
||||
);
|
||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -230,10 +248,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 +637,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) {
|
||||
|
||||