Compare commits

..
Author SHA1 Message Date
Codeman maintainer ef071ed485 feat(agent-skill): give the codeman skill to every agent CLI, not just Claude
The agent skill only reached Claude Code: injection wrote
.claude/skills/codeman, which no other CLI reads, and the per-session
preamble was seeded for claude alone. A Codex, Gemini, OpenCode,
Antigravity, Grok, DeepSeek or OMP session inside Codeman never learned it
could start and drive workers.

- Registry field capabilities.agentSkillDir (a closed enum, since
  clis.json can set it and it decides where the server writes) names the
  skills directory each CLI scans. Claude keeps .claude/skills; those seven
  CLIs get the same skill in <case>/.agents/skills/codeman (each measured to
  scan it; Claude Code does not, so it never sees two copies). pi gets none:
  a project skill makes pi ask "Trust project folder?" at startup.
- The preamble is seeded for every local session whose CLI takes the
  skill; nothing in it is claude-specific.
- The skill text is CLI-neutral and names the two CLI-side failures to
  report instead of retrying: a sandbox that blocks loopback (Codex's
  default), and commands that do not carry this session's environment
  (Codex 0.162 runs them in a shared app-server daemon).
- POST /api/sessions injects after the launch pre-flight, so a CLI that
  cannot start leaves nothing in the workspace.
- codeman skill install gains --mode; uninstall --case removes the copy
  from every directory the registry can write.
- The settings toggle moves to Agents & CLIs > All agent CLIs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:33:47 +02:00
Ark0NandClaude Opus 5.5 aa3730aace Merge pull request #589: docs(cron): document job API and launch statuses
Fixes #587. Supersedes #597, closed as a duplicate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:41:08 +02:00
Codeman maintainer 6234aae919 docs(readme): codeman skill GIF with the CRT tile grid and a mixed fleet
The agent skill section now shows a real run end to end: one short
prompt typed into Claude Code, the tile grid powering on with the CRT
entrance, then a DeepSeek Harness worker on a local qwen model and a
Claude Code worker powering on as new tiles, with lineage lines back to
the lead. 1280 wide, linking a 3600x2025 still.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:29:01 +02:00
w3lld1 34439d18e5 docs(cron): address linked guide and run history review
Signed-off-by: w3lld1 <42353747+w3lld1@users.noreply.github.com>
2026-10-10 09:27:54 +02:00
w3lld1 45a205b4e5 docs(cron): document job API and launch statuses
I corrected the toolbar location and documented the existing cron request and response fields, with checks for route, schema, and status coverage.
2026-10-10 06:49:58 +02:00
41 changed files with 856 additions and 1605 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': minor
---
Tiles move by hand, and can leave the window. With gesture control on, pinch a tile and carry it onto another tile (they swap) or an empty cell (it moves there), or carry a tab onto the grid to tile it. A new **Detach Tiles** setting (App Settings → Appearance, per device, off by default) lets you drag a tile's header out of the browser to open it in its own window, drop it on another Codeman window's tiles to move it there, and drag a popped-out window's title back onto any grid. The tile's ⋯ menu gets "Open in a new window" too.
+3 -3
View File
File diff suppressed because one or more lines are too long
+8 -8
View File
@@ -733,23 +733,23 @@ For AI agents and automation that control Codeman without a browser: an agent th
### The agent skill (start here)
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.
Everything in this section also ships as an **agent skill**, [`skills/codeman`](skills/codeman/SKILL.md), in the `SKILL.md` format Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. 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>
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
</p>
#### Step 1: install it
| How | Command | Scope |
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, works for any skills-aware agent |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
| Bundled CLI | `codeman skill install --case <name>` | One case only |
| Web UI | App Settings → Agents & CLIs → Claude → **Agent Skill** | Auto-injects into each case on Claude session create (`agentSkillEnabled`, SYNCED, default off) |
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, default off) |
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a `skills/codeman` you wrote yourself.
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every Codeman-managed copy in that case, whichever CLI's directory it sits in), and never touches a `skills/codeman` you wrote yourself.
#### Step 2: ask for things
@@ -802,7 +802,7 @@ Every recipe in there was verified against a live server, and the comments recor
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) the skill refuses to act and does not guess an API URL, so a global install costs an unrelated Claude Code session nothing.
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that `.claude/` dir). Remove them per case with `codeman skill uninstall --case <name>`.
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
---
@@ -946,7 +946,7 @@ 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`"*.
The skill above drives the API with `curl` and `jq` from a preamble Codeman seeds per session. Where it does not reach (a `pi` or `shell` session, a machine without `jq`), the same environment is still there (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane), so `codeman agent` packages the same verbs 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
+88 -1
View File
@@ -87,6 +87,93 @@ the HTTP status.
Adding a new error code is non-breaking; removing or renaming one is a major change.
## Cron jobs
Saved jobs and their launch history are separate from the legacy `/api/scheduled`
duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...`
is the unversioned alias. These routes use the response envelope above; the table
lists the value inside `data` on success.
| Method | Path | Request body | Response `data` |
| --- | --- | --- | --- |
| GET | `/api/v1/cron/jobs` | None | `CronJob[]` |
| POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` |
| GET | `/api/v1/cron/jobs/:id` | None | `CronJob` |
| PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` |
| DELETE | `/api/v1/cron/jobs/:id` | None | `{}` |
| PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` |
| POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` |
| GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` |
| GET | `/api/v1/cron/runs` | None | `CronJobRun[]` |
### Job request fields
The create body requires `name`, `agentType`, `workingDir`, `promptMode`,
`inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields
are required according to the selected prompt and schedule:
| Field | Type / validation |
| --- | --- |
| `name` | String, 1–200 characters |
| `agentType` | A supported session mode (including `shell`) |
| `workingDir` | Existing, allowed working-directory path |
| `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs |
| `promptMode` | `inline_text` or `prompt_file_path` |
| `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters |
| `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires |
| `inputMode` | `paste` or `typed` |
| `scheduleType` | `once`, `interval`, `daily`, or `weekly` |
| `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds |
| `intervalMinutes` | Required for `interval`; integer from 1 to 525600 |
| `dailyTime` | Required for `daily`; `HH:MM` in server-local time |
| `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) |
| `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time |
| `enabled` | Boolean |
| `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only |
| `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` |
| `notes` | Optional string, at most 2000 characters |
`PUT /jobs/:id` accepts any subset of these fields, then validates the merged job.
When changing `promptMode` or `scheduleType`, supply the fields the new mode needs.
`Run Now` works even when the job is disabled, bypasses the scheduled concurrency
policy, and does not change the schedule. `activeAgents` counts live sessions of
the same agent type, excluding sessions created by this job.
For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now`
also closes the previous run's session before launching, even if it is still working.
### Job and run response fields
`CronJob` contains the request fields plus server-maintained `id`, optional
`owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`,
`lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix
milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`.
`lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format.
`CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`,
`startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`,
`triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`.
Run times are also Unix milliseconds. Status is one of `created`,
`session_started`, `prompt_sent`, `failed`, or `skipped`.
Prompt delivery continues asynchronously after session launch, so `Run Now` can
return `session_started` before the prompt is sent. Read run history for subsequent
updates, but do not assume a terminal status will follow: if the session is closed
during the readiness wait or the server restarts before delivery, the run can remain
`session_started` indefinitely with `finishedAt: null`.
`finishedAt` refers to the launch/prompt-delivery attempt, **not completion
of the agent's task**; `prompt_sent` does not prove that the task succeeded.
In multi-user mode, list/history endpoints filter to accessible jobs. An unknown
or inaccessible job returns `NOT_FOUND`. Job creation and updates can return
`403 FORBIDDEN` for a working directory outside the owner's workspace or a shell /
launch-command job without the required privilege grant. Invalid definitions or
working directories return `INVALID_INPUT`; launch/delivery failures are recorded
on the run, so inspect its `status` and `errorMessage` even after an HTTP success.
See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules.
See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and
`cron:runUpdated` SSE events.
## Long-polling (agent wait)
Three calls block until something happens instead of answering immediately. They
@@ -468,7 +555,7 @@ geometry was read. The capture runs synchronous tmux calls on the server; the
## 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.
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
+2 -4
View File
@@ -143,7 +143,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant.
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused).
@@ -841,15 +841,13 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
⚠️ **Detach Tiles** (`tileDetachEnabled`, per-device, default OFF: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; App Settings → Appearance, its row shown on desktop only). A tile leaves the grid for a window of its own, and comes back into any Codeman window's grid, by dragging, with nothing new on the server: a pop-out is another client of the same session (`detachSession`), and a tile is a `TerminalTile` in whichever window has it. (1) A header let go OUTSIDE the window opens it with `detachSession(id, placement)`, the tile's size (at least 480x320), its header near the drop point, after `TILE_TEAR_OFF_SETTLE_MS` (120 ms) that a claim from another window cancels. "Outside" is the dragend's own point off the viewport with `dropEffect: 'none'`; where a browser reports no point (0, 0) it is the page's record of a `dragleave` at the viewport's edge with nothing entered, cleared by any later `dragover`. A drop that any target took (`dropEffect: 'move'`, in this window or another) never opens one, a drop inside on nothing changes nothing, and with the setting on a lone or zoomed tile drags too (out only: no target here takes it). (2) A header dropped on ANOTHER Codeman window's tile or empty cell moves it there: that window's `_acceptTabDrops` takes a drag that is none of its own and carries `application/x-codeman-tile` (`_isForeignTileDrag`; the id is only readable on drop), docks it through the target's own drop (`_dockForeignTile` → `dropSessionOnTile` / `dropSessionOnSlot`, so `_reorderTiles` stays the one move path) and posts `tile-adopted` on the `codeman-windows` channel. Only the window the tile was dragged FROM acts on that (its drag still settling, or ended within `TILE_ADOPT_WINDOW_MS`): the tile goes with `focus: false`, and a last tile closes the grid onto the welcome screen, never onto that session, since two windows sizing one PTY garble it. (3) A pop-out's title (`#soloSessionTitle`, `_installSoloTileHandle`) carries the same type, so it drags back onto any grid: docking a popped-out session `_redock`s it first (a detached session never mounts), posts `close-request`, and ignores that pop-out's `detached` answers for `TILE_DOCK_GRACE_MS` (`_tileDockedRecently`), which would otherwise take the new tile straight back off. A pop-out that gets `close-request` is RELEASED (`_releaseSoloWindow`): it posts `redocked` at once, stops answering roll-calls, then closes; one a script may not close (opened by typing its URL) says where the session went instead of staying a pop-out every dashboard would mark detached again. A tile's ⋯ menu offers "Open in a new window" with the setting on (`detachTile`, placed over the tile): the keyboard's way to the drag. ⚠️ Chrome keeps a script-opened window on the opener's screen unless the page holds the Window Management permission, so a drop on another monitor opens at this screen's edge (drag the window across from there), and Wayland ignores the position. The gesture overlay reaches all of it through `tileAtPoint` / `canGrabTile` / `tileDropTargetAt` / `dropOnTileTarget` / `isOverTileGrid` / `detachTileAtPoint`, never move logic of its own; its pop-out has no click behind it, so a popup blocker refuses it unless the site's pop-ups are allowed (detachSession's toast says so, and the tile stays). Tests: `test/tile-grid-detach.test.ts`.
### Gesture control: the setting
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Terminal & Input → Scrolling & rendering (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
### Gesture control: the source package
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`. The tile grid's verbs (a tile pinched anywhere on it and carried onto another tile or an empty cell; a tab carried onto the grid joins it there; with Detach Tiles a tile let go outside the grid pops out) ask tile-grid.js's gesture helpers (see Tile grid, Detach Tiles) and never move tiles themselves, so the hand and the mouse cannot disagree about a drop.
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
### Theme skins
+12
View File
@@ -128,6 +128,18 @@ 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.
### Where the agent skill lands
`agentSkillInjection` says a CLI's sessions get the agent skill (`skills/codeman`) and its per-session preamble when the synced `agentSkillEnabled` setting is on; `agentSkillDir` says which workspace directory the CLI scans for skills, read through `agentSkillDir()` (hooks-config.ts), so no call site branches on a CLI id. Absent with the boolean on means `.claude/skills`, which is what the boolean meant before the field existed.
| CLI | Skill directory |
| --- | --- |
| claude | `.claude/skills/codeman` |
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
| pi, shell | none |
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
## 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.
+2 -2
View File
@@ -5,7 +5,7 @@ spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) sessi
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
- **UI**: the **⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal`).
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
@@ -24,7 +24,7 @@ Claude session in `~/proj` and tell it to update dependencies and open a PR."_
### In the browser
1. Click **⏰ Cron** in the header.
1. Click **⏰ Cron** in the bottom toolbar.
2. Click **+ New Job**.
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
**prompt** (inline text or a file path), pick a **schedule**, and leave
+4 -3
View File
@@ -163,9 +163,10 @@ respawn, Ralph, the orchestrator, search, and admin. Each route module carries a
If the caller is an agent running _inside_ a Codeman session, install the packaged
agent skill instead of teaching it these calls by hand: `skills/codeman` in the repo
(`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install
[--case <name>]`, or the synced `agentSkillEnabled` App Setting for automatic
per-case injection on Claude session create). The skill carries the guard, the
safety rules, and verified wait/orchestration recipes.
[--case <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
automatic per-case injection on session create, into the directory each CLI scans for
skills). The skill carries the guard, the safety rules, and verified
wait/orchestration recipes.
The common ones:
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

+15 -7
View File
@@ -4,8 +4,8 @@ Saved, named jobs that start a session and send it a prompt on a schedule. Cron
sessions: *every weekday at 03:00, open a Claude session in `~/proj` and tell it to update
dependencies and open a PR.*
The ⏰ **Cron** header button is opt-in. Turn it on in
**App Settings → Header & Panels**.
The ⏰ **Cron** button in the bottom toolbar is opt-in. Turn it on under
**App Settings → Header & Panels → Scheduling**.
## Creating a job
@@ -96,11 +96,19 @@ The skip policy has the details you would want it to have:
Every fire is recorded per job, with a status:
| Status | Meaning |
| --------- | -------------------------------------------------------------------- |
| `created` | The run started and a session was created. |
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
| `failed` | The prompt could not be resolved, or the working directory was gone. |
| Status | Meaning |
| ----------------- | ----------------------------------------------------------------- |
| `created` | The run record was created; the session has not started yet. |
| `session_started` | The session started; prompt delivery is still pending. |
| `prompt_sent` | The prompt was sent to the session. |
| `skipped` | The concurrency policy blocked it. Not counted as a run. |
| `failed` | Prompt resolution, session launch, or prompt delivery failed. |
History records whether the session started and the prompt was delivered, **not whether
the agent's task succeeded**. `prompt_sent` is not a task-completion signal.
If the session is closed during the readiness wait or the server restarts before
delivery, a run can remain `session_started` indefinitely with `finishedAt: null`;
clients must not poll forever waiting for a terminal status.
The schedule is advanced **before** the session launches, so a slow start cannot cause the
same job to re-trigger.
+23 -15
View File
@@ -4,26 +4,34 @@ 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.
Three routes. Start with the skill, whichever CLI the agent is. Where the skill does not
reach (a pi or shell session), use the `codeman agent` commands. Raw HTTP is there for
everything else.
## The agent skill
A Claude Code skill that teaches the agent the whole API, so you ask in plain English
instead of pasting endpoint documentation into prompts.
A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP
and most other agent CLIs read. It teaches the agent the whole API, so you ask in plain
English instead of pasting endpoint documentation into prompts.
### Install it
| How | Command | Scope |
| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. The way to give it to pi. |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. Pick this or `codeman skill install`, not both, or the skill is listed twice (`codeman` and `codeman:codeman`). |
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
| Bundled CLI | `codeman skill install --case <name>` | One case. |
| Web UI | **App Settings → Agents & CLIs → Claude → Agent Skill** | Injects into each case when a Claude session is created. Off by default. |
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Not pi: a project skill makes pi ask to trust the folder at startup. Off by default. |
`codeman skill uninstall [--case <name>]` reverses the CLI installs, and never touches a
`skills/codeman` you wrote yourself.
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every
Codeman-managed copy in that case), and never touches a `skills/codeman` you wrote yourself.
Two things to know when the agent is **Codex**: its default sandbox blocks network access
even to this machine, so the skill needs **Bypass approvals and sandbox** (App Settings,
Codex); and Codex 0.162 runs its commands in a shared background process, whose Codeman
variables can belong to the session that started it. The skill checks for both and says so
instead of acting.
### Then just ask
@@ -62,12 +70,12 @@ 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`"*.
The skill drives the API with `curl` and `jq`. Where it does not reach (a pi or shell
session, a machine without `jq`), the agent runs in the same environment, 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
+26
View File
@@ -92,6 +92,32 @@ Roughly 235 handlers across 26 route modules. By domain:
Each route module documents its own endpoints in its file header.
## Cron jobs
Saved scheduled jobs are distinct from the legacy `/api/scheduled` loops. Their
versioned endpoints are:
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/v1/cron/jobs` | List jobs |
| POST | `/api/v1/cron/jobs` | Create a job |
| GET | `/api/v1/cron/jobs/:id` | Read a job |
| PUT | `/api/v1/cron/jobs/:id` | Update a job (partial body) |
| DELETE | `/api/v1/cron/jobs/:id` | Delete a job |
| PUT | `/api/v1/cron/jobs/:id/enabled` | Enable/disable with `{ enabled: boolean }` |
| POST | `/api/v1/cron/jobs/:id/run` | Run now, without changing the schedule |
| GET | `/api/v1/cron/jobs/:id/runs` | Read a job's run history |
| GET | `/api/v1/cron/runs` | Read all accessible run history |
Responses use the envelope above: job lists/history have arrays in `data`, a
single-job GET has the job itself, create/update/enable have `{ job }`, delete has
`{}`, and Run Now has `{ run, activeAgents }`. Prompt delivery is asynchronous;
`session_started` and `prompt_sent` describe launch/delivery, not task success.
The [cron API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#cron-jobs)
lists all request and response fields, validation, and ownership restrictions.
[Creating a job](Cron-Jobs) covers the UI and schedule semantics.
## Long-polling instead of polling
Three calls block until something happens, so an agent driving Codeman from a shell can wait
+1 -1
View File
@@ -145,7 +145,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Allowed Tools | The list used by the explicit mode. |
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
| Agent Teams | Experimental teams. Also needs the CLI's own environment flag. |
| 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). |
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). 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. |
+1 -1
View File
@@ -258,7 +258,7 @@ Two extras depending on the device:
| Respawn | Session Options | [Keeping Agents Running](Keeping-Agents-Running) |
| Ralph | Session Options | [Autonomous Loops](Autonomous-Loops) |
| Orchestrator | Toolbar | [Autonomous Loops](Autonomous-Loops) |
| Cron | Header ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
| Cron | Bottom toolbar ⏰ (opt-in) | [Cron Jobs](Cron-Jobs) |
| Subagents | Automatic while agents run | [Watching Agents Work](Watching-Agents-Work) |
| Ultracode | Header (opt-in) | [Watching Agents Work](Watching-Agents-Work) |
| File Viewer | Header | [Working With Files](Working-With-Files) |
-7
View File
@@ -133,13 +133,6 @@ its CSP for WebAssembly). Deploy = copy the bundle into Codeman's
drop point. Pinch the panel again to move it anywhere. It stays inside the
camera-owning page, so the hand keeps control (the old `window.app.detachSession`
`window.open` was a one-way trip). A small twitch-and-release cancels.
- **Tiles** (tile grid open): pinch anywhere on a tile and carry it onto
another tile (the two trade places) or an empty cell (it moves there). A
session tab carried onto a tile or an empty cell joins the grid there. With
**Detach Tiles** on (App Settings, per device, off by default) a tile let go
outside the grid opens as a window of its own, which needs the site's pop-ups
allowed (a hand is no click). The moves are tile-grid.js's own
(`tileDropTargetAt` / `dropOnTileTarget` / `detachTileAtPoint`).
- **Run / Run Shell** — pinch over the **Run** (`#runBtn`) or **Run Shell**
(`.btn-shell`) toolbar button and release in place to fire it; drifting too
far first cancels the tap. The button list is `CLICK_SELECTOR` in `entry.ts`.
+6 -182
View File
@@ -27,14 +27,6 @@
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
// in place → fires the button's real click handler. Drift too far first and
// it's treated as a stray move, not a tap.
// • Tile "grab-to-move": with the tile grid open, pinch anywhere on a tile
// and carry it onto another tile (the two trade places) or an empty cell
// (it moves there). With Detach Tiles on (App Settings, per device, off by
// default) a tile let go OUTSIDE the grid opens as a window of its own.
// A session TAB carried onto a tile or an empty cell joins the grid there,
// as a mouse-dragged tab does. Every move goes through app.js's own paths
// (tile-grid.js tileDropTargetAt / dropOnTileTarget / detachTileAtPoint), so
// the hand and the mouse can never disagree about what a drop does.
//
// Why in-page floats, not OS-window detach (decided 2026-06-08, see
// docs/MULTIMONITOR_DESIGN.md): the hand only exists in the one page that owns
@@ -60,22 +52,11 @@ declare global {
saveSubagentWindowStates?: () => void;
subagentWindowZIndex?: number;
ultracodeWindowZIndex?: number;
// The tile grid (tile-grid.js). All optional: an older dashboard without
// them simply has no tile verbs for the hand.
tileAtPoint?: (x: number, y: number) => string | null;
canGrabTile?: (id: string) => boolean;
tileDropTargetAt?: (x: number, y: number, draggedId: string) => TileTarget | null;
dropOnTileTarget?: (draggedId: string, target: TileTarget) => boolean;
isOverTileGrid?: (x: number, y: number) => boolean;
tileDetachEnabled?: () => boolean;
detachTileAtPoint?: (id: string, x: number, y: number, at?: { offsetX: number; offsetY: number }) => boolean;
};
}
}
const TAB_SELECTOR = '.session-tab';
/** A tile of the open tile grid (tile-grid.js), keyed by `data-session-id`. */
const TILE_SELECTOR = '#tileGrid .tile';
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
const PANEL_SELECTOR = '.cg-float';
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
@@ -100,20 +81,11 @@ const FLOAT_H = 420;
const DETACH_PULL_PX = 70;
/** If a button-pinch drifts more than this, it's a stray move, not a tap. */
const TAP_CANCEL_PX = 45;
/** How far (px) a grabbed tile must travel before it targets anything: a pinch
* that barely moves is a stray one, and the tile stays where it is. */
const TILE_MOVE_PX = 40;
/** The width (px) of the small copy of a grabbed tile that follows the hand. */
const TILE_GHOST_W = 280;
/** First letter colours: cyan left, violet right; green while pinching. */
const handColor = (handedness: string, pinching: boolean): string =>
pinching ? '#4ade80' : handedness === 'Right' ? '#a78bfa' : '#38bdf8';
/** Where a session carried by the hand would land in the tile grid: another
* tile, or an empty cell (tile-grid.js tileDropTargetAt). */
type TileTarget = { kind: 'tile'; id: string; el: HTMLElement } | { kind: 'cell'; cell: number; el: HTMLElement };
/** A floating in-page session panel (an iframe of `/session/:id`) the hand can
* place and re-grab. Stays in this page's DOM, so it never leaves hand reach. */
interface FloatingPanel {
@@ -135,29 +107,6 @@ type Grab =
oy: number;
/** Pulled past the float-out threshold at least once. */
armed: boolean;
/** With the tile grid open: the tile or empty cell it would join (highlighted). */
target: TileTarget | null;
}
| {
/** A tile of the open grid, carried to another cell or (Detach Tiles)
* out of the grid. The tile itself never moves until the drop: it dims
* (`tile--dragging`, the mouse drag's own class) and a small copy of
* it follows the hand. */
kind: 'tile';
id: string;
el: HTMLElement;
ghost: HTMLElement;
/** Where in the tile the hand closed, so a pop-out window lands under it. */
offsetX: number;
offsetY: number;
ox: number;
oy: number;
/** Travelled past TILE_MOVE_PX at least once. */
armed: boolean;
/** The tile or empty cell it would drop onto (highlighted). */
target: TileTarget | null;
/** Outside the grid with Detach Tiles on: letting go opens a window. */
out: boolean;
}
| {
kind: 'panel';
@@ -275,7 +224,7 @@ class GestureBridge {
await this.gc.start();
this.running = true;
this.button.classList.add('on');
this.status.textContent = 'on: pinch a tab, tile, window, or button';
this.status.textContent = 'on — pinch a tab, window, or button';
} catch (err) {
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
// (number/string), so `(err as Error).message` was logging "undefined".
@@ -346,35 +295,6 @@ class GestureBridge {
return;
}
// A tile of the open tile grid → carry it to another cell, or (Detach
// Tiles) out of the grid. Anywhere on the tile: the hand is choosing the
// whole tile, as with the agent windows. A tile that can do neither (alone
// or zoomed, Detach Tiles off) is left alone, so the pinch falls through.
const tileId = window.app?.tileAtPoint?.(x, y) ?? null;
const tileEl = tileId ? this.hitClosest(x, y, TILE_SELECTOR) : null;
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
const rect = tileEl.getBoundingClientRect();
const ghost = this.tileGhost(tileEl, rect);
document.body.append(ghost);
tileEl.classList.add('tile--dragging');
this.grabs.set(hand, {
kind: 'tile',
id: tileId,
el: tileEl,
ghost,
offsetX: Math.max(0, Math.min(x - rect.left, rect.width)),
offsetY: Math.max(0, Math.min(y - rect.top, rect.height)),
ox: x,
oy: y,
armed: false,
target: null,
out: false,
});
this.positionGhost(ghost, x, y);
this.status.textContent = 'moving tile';
return;
}
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
const tab = this.hitClosest(x, y, TAB_SELECTOR);
const id = tab?.dataset.id;
@@ -388,7 +308,7 @@ class GestureBridge {
document.body.append(ghost);
tab.classList.add('cg-grabbed');
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false, target: null });
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false });
this.positionGhost(ghost, x, y);
return;
}
@@ -405,42 +325,13 @@ class GestureBridge {
private onDrag(hand: string, x: number, y: number): void {
const grab = this.grabs.get(hand);
if (grab?.kind === 'tile') {
this.positionGhost(grab.ghost, x, y);
if (!grab.armed && Math.hypot(x - grab.ox, y - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
if (!grab.armed) return;
const app = window.app;
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
this.setTileTarget(grab, target);
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x, y);
grab.out = out;
grab.ghost.classList.toggle('cg-armed', !!target);
grab.ghost.classList.toggle('cg-out', out);
this.status.textContent = target
? target.kind === 'tile'
? 'release to swap tiles'
: 'release to move the tile here'
: out
? 'release to open in a new window'
: 'moving tile';
return;
}
if (grab?.kind === 'tab') {
this.positionGhost(grab.ghost, x, y);
const pulled = Math.hypot(x - grab.ox, y - grab.oy) >= DETACH_PULL_PX;
// The tile grid open: a tile or an empty cell under the hand takes the
// tab there, as a mouse-dragged tab does.
const target = pulled ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
this.setTileTarget(grab, target);
if (pulled !== grab.armed || targetChanged) {
if (pulled !== grab.armed) {
grab.armed = pulled;
grab.ghost.classList.toggle('cg-armed', pulled);
this.status.textContent = target
? 'release to tile it here'
: pulled
? 'release to float out'
: 'on — pinch a tab';
this.status.textContent = pulled ? 'release to float out' : 'on — pinch a tab';
}
return;
}
@@ -463,44 +354,17 @@ class GestureBridge {
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove('cg-tap-armed');
this.taps.delete(hand);
this.status.textContent = 'on: pinch a tab, tile, window, or button';
this.status.textContent = 'on — pinch a tab, window, or button';
}
}
private onDrop(hand: string, x: number, y: number): void {
const grab = this.grabs.get(hand);
if (grab?.kind === 'tile') {
this.grabs.delete(hand);
grab.ghost.remove();
grab.el.classList.remove('tile--dragging');
this.setTileTarget(grab, null);
const app = window.app;
if (!grab.armed) {
this.flash('cancelled');
return;
}
// Asked again at the drop: the grid may have changed since the last frame.
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
if (target) {
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? 'moved tile' : 'cancelled');
} else if (grab.out && !app?.isOverTileGrid?.(x, y)) {
// A window opened with no click behind it is what popup blockers stop:
// detachSession then says so in a toast and the tile stays.
const opened = app?.detachTileAtPoint?.(grab.id, x, y, { offsetX: grab.offsetX, offsetY: grab.offsetY });
this.flash(opened ? 'opened in a new window' : 'pop-out blocked: allow popups for this site');
} else {
this.flash('cancelled');
}
return;
}
if (grab?.kind === 'tab') {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove('cg-grabbed');
this.setTileTarget(grab, null);
const target = grab.armed ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? 'tiled' : 'cancelled');
else if (grab.armed) this.floatPanel(grab.id, x, y);
if (grab.armed) this.floatPanel(grab.id, x, y);
else this.flash('cancelled');
return;
}
@@ -641,33 +505,6 @@ class GestureBridge {
ghost.style.top = `${y}px`;
}
/** A small copy of a tile to follow the hand: its header (a copy: no
* listeners come along) over an empty body, in the tile's proportions. */
private tileGhost(tileEl: HTMLElement, rect: DOMRect): HTMLElement {
const ghost = el('div', 'cg-ghost cg-tile-ghost');
const width = Math.min(TILE_GHOST_W, rect.width);
ghost.style.width = `${width}px`;
ghost.style.height = `${Math.max(48, Math.round((width * rect.height) / Math.max(1, rect.width)))}px`;
const header = tileEl.querySelector('.tile-header');
if (header) {
const copy = header.cloneNode(true) as HTMLElement;
copy.removeAttribute('draggable');
ghost.append(copy);
}
ghost.append(el('div', 'cg-tile-ghost-body'));
return ghost;
}
/** Highlights the tile or empty cell a carried session would drop onto
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
private setTileTarget(grab: { target: TileTarget | null }, target: TileTarget | null): void {
if (grab.target?.el !== target?.el) {
grab.target?.el.classList.remove('tile--drop-target');
target?.el.classList.add('tile--drop-target');
}
grab.target = target;
}
private cancelAllGrabs(): void {
// Floats themselves persist (they're placed windows) — only release any
// in-progress grab cleanly, restoring a moved panel's interactivity.
@@ -675,11 +512,6 @@ class GestureBridge {
if (grab.kind === 'tab') {
grab.ghost.remove();
grab.tab.classList.remove('cg-grabbed');
this.setTileTarget(grab, null);
} else if (grab.kind === 'tile') {
grab.ghost.remove();
grab.el.classList.remove('tile--dragging');
this.setTileTarget(grab, null);
} else if (grab.kind === 'panel') {
grab.panel.el.style.pointerEvents = '';
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
@@ -693,7 +525,6 @@ class GestureBridge {
document
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t) => t.classList.remove('tile--dragging'));
}
private onStatus(fps: number, hands: HandState[]): void {
@@ -776,13 +607,6 @@ function injectStyles(): void {
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
.cg-tile-ghost {
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
}
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
.cg-dock {
position: fixed; right: 12px; bottom: 156px; z-index: ${Z + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
+29 -12
View File
@@ -1,11 +1,13 @@
---
name: codeman
description: >-
Drive Codeman, the session manager this agent is running inside, over its HTTP API:
list sessions, start worker sessions, send them prompts, block until they finish
(wait / wait-output / send-and-wait), read their output, and clean up; where
available, message claude workers directly (Claude Code cross-session messaging).
Use when asked to orchestrate or parallelize work across Codeman sessions, watch
Drive Codeman, the session manager this agent is running inside, over its HTTP API,
from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
another session, or start and manage workers. Only usable inside a Codeman-managed
session (CODEMAN_MUX=1); refuse to act otherwise.
---
@@ -29,17 +31,31 @@ 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.
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
That 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, and it is also your own way through when this
machine has no `jq` for the preamble below.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
the next call, and `$$` is a different pid. **The filesystem does survive**, so write
@@ -47,8 +63,9 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
single most likely way to break a run).
**Codeman seeds the preamble file for you** when it spawns a claude session (server
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
**Codeman seeds the preamble file for you** when it spawns your session (claude since
server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
usually nothing at all: these are the two lines every
later call opens with, and your first REAL call performs them anyway:
```bash
+29 -12
View File
@@ -1,11 +1,13 @@
---
name: codeman
description: >-
Drive Codeman, the session manager this agent is running inside, over its HTTP API:
list sessions, start worker sessions, send them prompts, block until they finish
(wait / wait-output / send-and-wait), read their output, and clean up; where
available, message claude workers directly (Claude Code cross-session messaging).
Use when asked to orchestrate or parallelize work across Codeman sessions, watch
Drive Codeman, the session manager this agent is running inside, over its HTTP API,
from any agent CLI (Claude Code, Codex, Gemini CLI, OpenCode, Antigravity, Grok
Build, DeepSeek Harness, OMP): list sessions, start worker sessions in any installed
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
another session, or start and manage workers. Only usable inside a Codeman-managed
session (CODEMAN_MUX=1); refuse to act otherwise.
---
@@ -29,17 +31,31 @@ 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.
This skill works the same whichever CLI you are: Codeman puts it where each CLI looks
for skills (`.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI,
OpenCode, Antigravity, Grok, DeepSeek and OMP). A **pi** or **shell** worker never gets
it, but has the same environment: tell it *"other sessions: `codeman agent --help`"*.
That 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, and it is also your own way through when this
machine has no `jq` for the preamble below.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
the next call, and `$$` is a different pid. **The filesystem does survive**, so write
@@ -47,8 +63,9 @@ the preamble to a file once and source it afterwards, rather than re-pasting a
hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the
single most likely way to break a run).
**Codeman seeds the preamble file for you** when it spawns a claude session (server
1.18.3+), so the bootstrap is usually nothing at all: these are the two lines every
**Codeman seeds the preamble file for you** when it spawns your session (claude since
server 1.18.3, the other CLIs since this skill reached them), so the bootstrap is
usually nothing at all: these are the two lines every
later call opens with, and your first REAL call performs them anyway:
```bash
+97 -13
View File
@@ -18,7 +18,16 @@ 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 {
agentSkillDir,
agentSkillDirFor,
CLAUDE_AGENT_SKILL_DIR,
installAgentSkillInto,
removeAgentSkillFrom,
type AgentSkillApplyResult,
} from './hooks-config.js';
import type { AgentSkillDir } from './config/cli-registry/types.js';
import { getCli, listClis } from './config/cli-registry/registry.js';
import { registerAgentCommands } from './cli-agent.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
@@ -140,7 +149,8 @@ export function resolveCliCasePath(name: string): string {
/**
* Resolve where `skill install` / `skill uninstall` operate. Global is
* `~/.claude/skills/codeman` (Claude Code's user-scope skill dir, read by every new
* session); `--case <name>` targets `<case>/.claude/skills/codeman`, resolved through
* session); `--case <name>` targets `<case>/<skillDir>/codeman` for the given skills
* directory (default: Claude Code's, `<case>/.claude/skills/codeman`), resolved through
* `resolveCliCasePath()` above. The web server's automatic per-case injection
* (`agentSkillEnabled`) covers multi-user spaces; this CLI is a local operator tool
* and stays single-user.
@@ -151,17 +161,18 @@ export function resolveCliCasePath(name: string): string {
*/
export function resolveSkillTargetPath(options: {
case?: string;
skillDir?: AgentSkillDir;
}): { target: string; missingCase?: undefined } | { target?: undefined; missingCase: string } {
if (options.case) {
const casePath = resolveCliCasePath(options.case);
if (!existsSync(casePath)) return { missingCase: casePath };
return { target: join(casePath, '.claude', 'skills', 'codeman') };
return { target: agentSkillDirFor(casePath, options.skillDir ?? CLAUDE_AGENT_SKILL_DIR) };
}
return { target: join(homedir(), '.claude', 'skills', 'codeman') };
}
/** Exit-owning wrapper around `resolveSkillTargetPath()` for the two commands below. */
function resolveSkillTarget(options: { case?: string }): string {
function resolveSkillTarget(options: { case?: string; skillDir?: AgentSkillDir }): string {
const resolved = resolveSkillTargetPath(options);
if (resolved.missingCase !== undefined) {
console.error(palette.err(`✗ Case not found: ${resolved.missingCase}`));
@@ -170,8 +181,44 @@ function resolveSkillTarget(options: { case?: string }): string {
return resolved.target;
}
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
/**
* The skills directory `skill install --mode <mode>` writes into, or why it cannot: a
* mode the registry gives no skill, or a user-level install for a CLI other than Claude
* Code (each CLI keeps its user-level skills somewhere else, and `npx skills add`
* already knows every one of those places).
*/
export function resolveSkillInstallDir(options: {
mode?: string;
case?: string;
}): { skillDir: AgentSkillDir; error?: undefined } | { skillDir?: undefined; error: string } {
const mode = options.mode ?? 'claude';
const cli = getCli(mode);
if (!cli) return { error: `Unknown mode: ${mode}` };
const skillDir = agentSkillDir(cli.capabilities);
if (!skillDir) return { error: `Mode ${mode} takes no Codeman agent skill` };
if (!options.case && skillDir !== CLAUDE_AGENT_SKILL_DIR) {
return {
error: `A user-level install for ${mode} goes through the skills installer: npx skills add Ark0N/Codeman --skill codeman -g (or pass --case <name>)`,
};
}
return { skillDir };
}
/**
* Every distinct skills directory the registry can inject into, so
* `skill uninstall --case` removes whatever any CLI's session left there.
*/
export function registryAgentSkillDirs(): AgentSkillDir[] {
const dirs = new Set<AgentSkillDir>();
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) dirs.add(dir);
}
return [...dirs];
}
/** An AgentSkillApplyResult in words, and whether it counts as success. */
function describeSkillResult(result: AgentSkillApplyResult, target: string): { ok: boolean; text: string } {
const messages: Record<AgentSkillApplyResult, { ok: boolean; text: string }> = {
installed: { ok: true, text: `Agent skill installed: ${target}` },
refreshed: { ok: true, text: `Agent skill refreshed (was stale): ${target}` },
@@ -187,7 +234,12 @@ function reportSkillResult(result: AgentSkillApplyResult, target: string): void
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
},
};
const message = messages[result];
return messages[result];
}
/** Print an AgentSkillApplyResult for humans; exit non-zero when nothing was done. */
function reportSkillResult(result: AgentSkillApplyResult, target: string): void {
const message = describeSkillResult(result, target);
if (message.ok) {
console.log(palette.ok(`✓ ${message.text}`));
} else {
@@ -205,9 +257,19 @@ skillCmd
.description('Install the agent skill globally (~/.claude/skills/codeman) or into one case')
.option('-g, --global', 'Install into ~/.claude/skills/codeman, picked up by every new session (the default)')
.option('-c, --case <name>', 'Install into <case>/.claude/skills/codeman instead (linked cases resolve too)')
.action(async (options: { global?: boolean; case?: string }) => {
.option(
'-m, --mode <mode>',
'With --case: install where that run mode looks for skills (codex: <case>/.agents/skills/codeman)',
'claude'
)
.action(async (options: { global?: boolean; case?: string; mode?: string }) => {
try {
const target = resolveSkillTarget(options);
const resolved = resolveSkillInstallDir(options);
if (resolved.error !== undefined) {
console.error(palette.err(`✗ ${resolved.error}`));
process.exit(1);
}
const target = resolveSkillTarget({ case: options.case, skillDir: resolved.skillDir });
reportSkillResult(await installAgentSkillInto(target), target);
} catch (err) {
console.error(palette.err(`✗ Failed to install agent skill: ${getErrorMessage(err)}`));
@@ -217,13 +279,35 @@ skillCmd
skillCmd
.command('uninstall')
.description('Remove a Codeman-managed agent skill copy (never touches a user-authored one)')
.description('Remove Codeman-managed agent skill copies (never touches a user-authored one)')
.option('-g, --global', 'Remove from ~/.claude/skills/codeman (the default)')
.option('-c, --case <name>', 'Remove from <case>/.claude/skills/codeman instead (linked cases resolve too)')
.option(
'-c, --case <name>',
"Remove every Codeman-managed copy from that case instead, whichever CLI's directory it is in (linked cases resolve too)"
)
.action(async (options: { global?: boolean; case?: string }) => {
try {
const target = resolveSkillTarget(options);
reportSkillResult(await removeAgentSkillFrom(target), target);
if (!options.case) {
const target = resolveSkillTarget(options);
reportSkillResult(await removeAgentSkillFrom(target), target);
return;
}
let failed = false;
let removedAny = false;
for (const skillDir of registryAgentSkillDirs()) {
const target = resolveSkillTarget({ case: options.case, skillDir });
const result = await removeAgentSkillFrom(target);
if (result === 'absent') continue;
removedAny = true;
const message = describeSkillResult(result, target);
if (message.ok) console.log(palette.ok(`✓ ${message.text}`));
else {
console.error(palette.err(`✗ ${message.text}`));
failed = true;
}
}
if (!removedAny) console.log(palette.ok(`✓ No Codeman-managed agent skill in case ${options.case}`));
if (failed) process.exit(1);
} catch (err) {
console.error(palette.err(`✗ Failed to remove agent skill: ${getErrorMessage(err)}`));
process.exit(1);
+7 -1
View File
@@ -15,7 +15,10 @@
import { z } from 'zod';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
import type { AgentSkillDir, LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
/** `capabilities.agentSkillDir`: every value must stay a fixed, relative, `..`-free path. */
export const AGENT_SKILL_DIRS = ['.claude/skills', '.agents/skills'] as const satisfies readonly AgentSkillDir[];
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -302,6 +305,9 @@ const capabilitiesSchema = z
respawn: z.boolean(),
effort: z.boolean(),
agentSkillInjection: z.boolean(),
// A closed list on purpose: it decides where the server WRITES, and this file is
// user-editable, so it can never be a free path (`..`, absolute).
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
statusLineTelemetry: z.boolean(),
// How many columns this CLI indents its transcript body by, so a copy can take
// that much off the clipboard. Bounded, because it is the whole strip: a copy
+28
View File
@@ -76,6 +76,21 @@ function agentDefaults(): Pick<
};
}
/**
* The agent skill in `<workspace>/.agents/skills/codeman`, the one project skills
* directory every CLI that spreads this scans. Measured per CLI on
* 2026-10-11 against the installed builds or their source: codex 0.162 (every dir from
* the git root down to cwd), gemini (cwd only, behind folder trust, which `--skip-trust`
* already answers), opencode 1.3 (cwd up to the worktree), agy 1.3.2 (its `.agents/`
* customization root), grok 1.0.5 (each tier from cwd up to the repo root), dsh 0.1.1
* (the git root, else cwd) and omp (cwd up to the git root). Claude Code does NOT read
* `.agents/skills`, so a case shared with a claude session lists each skill once there.
*/
const AGENTS_DIR_SKILL: Pick<CliEntry['capabilities'], 'agentSkillInjection' | 'agentSkillDir'> = {
agentSkillInjection: true,
agentSkillDir: '.agents/skills',
};
// `accent` on every entry below (except SHELL, which the frontend renders no
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
@@ -278,6 +293,7 @@ const CLAUDE: CliEntry = {
respawn: true,
effort: true,
agentSkillInjection: true,
agentSkillDir: '.claude/skills',
statusLineTelemetry: true,
model: { source: 'claude-settings-file' },
privilegedParams: [],
@@ -494,6 +510,7 @@ const OPENCODE: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-and-mouse',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
@@ -627,6 +644,7 @@ const CODEX: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// Codex draws `› Ask Codex to do anything` on its composer row and
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
@@ -816,6 +834,7 @@ const GEMINI: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-full',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live Gemini CLI 0.63.0 pane (capture-pane every 300 ms through real
@@ -924,6 +943,7 @@ const ANTIGRAVITY: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
@@ -1019,6 +1039,11 @@ const PI: CliEntry = {
},
capabilities: {
...agentDefaults(),
// No agent skill (agentSkillInjection stays false): any project-level `.agents/skills`
// or `.pi/skills` makes pi ask "Trust project folder?" at startup
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
@@ -1169,6 +1194,7 @@ const GROK: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured
// (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full
// erase-scrollback+DECSET strip.
@@ -1341,6 +1367,7 @@ const DEEPSEEK: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// Definitive rather than inferred: the harness TUI reports idle/working/blocked to a
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
// the session can disarm the bridge, and docker/remote cannot reach it at all.
@@ -1506,6 +1533,7 @@ const OMP: CliEntry = {
},
capabilities: {
...agentDefaults(),
...AGENTS_DIR_SKILL,
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
altScreen: 'strip-mux-only',
+21
View File
@@ -69,6 +69,14 @@ export type Cond =
*/
export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single';
/**
* Workspace-relative directories an agent CLI scans for project skills. A closed list
* rather than a free path: the value decides where the server WRITES, and
* `~/.codeman/clis.json` can set it, so it must never be able to name `..` or an
* absolute path.
*/
export type AgentSkillDir = '.claude/skills' | '.agents/skills';
/** One argv element. */
export type ArgSpec =
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
@@ -515,7 +523,20 @@ export interface CliCapabilities {
ralph: boolean;
respawn: boolean;
effort: boolean;
/**
* Codeman may write its agent skill (`skills/codeman`) into this CLI's workspace and
* seed its per-session preamble (behind the synced `agentSkillEnabled` setting,
* add-only, marker-owned). Where it lands comes from `agentSkillDir` below.
*/
agentSkillInjection: boolean;
/**
* The workspace-relative directory this CLI scans for project skills, so the skill
* lands at `<workspace>/<agentSkillDir>/codeman/SKILL.md` where the CLI discovers it.
* Measured per CLI. Absent with `agentSkillInjection: true` means Claude Code's
* `.claude/skills`, which is what the boolean meant before this field existed, so a
* `~/.codeman/clis.json` written against the old schema keeps its behaviour.
*/
agentSkillDir?: AgentSkillDir;
statusLineTelemetry: boolean;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
+54 -20
View File
@@ -36,6 +36,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js';
import type { AgentSkillDir, CliCapabilities } from './config/cli-registry/types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
@@ -1228,8 +1229,8 @@ interface AgentSkillFile {
*/
async function readAgentSkillSource(): Promise<AgentSkillFile[]> {
const src = agentSkillSourceDir();
const skill = await readFile(join(src, 'SKILL.md'), 'utf-8');
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skill.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
const skillMd = await readFile(join(src, 'SKILL.md'), 'utf-8');
const files: AgentSkillFile[] = [{ relPath: 'SKILL.md', content: `${skillMd.trimEnd()}\n\n${AGENT_SKILL_MARKER}\n` }];
let referenceNames: string[] = [];
try {
referenceNames = (await readdir(join(src, 'reference'))).filter((name) => name.endsWith('.md')).sort();
@@ -1285,7 +1286,8 @@ export type AgentSkillApplyResult =
/**
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
* directory, e.g. `<case>/.claude/skills/codeman` or `~/.claude/skills/codeman`).
* directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
* `<case>/.agents/skills/codeman`).
*
* Refuses two shapes rather than writing through them:
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
@@ -1336,16 +1338,20 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
}
/**
* Seed a claude session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
* Seed a session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
* default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent
* skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the
* model has to type out (measured live: that paste alone cost a spawn run ~47 s of
* generation time). The path formula must match the skill's
* `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so
* reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the
* shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (a
* remote or in-container HOME is not this filesystem) and treat it as best-effort: the
* skill's §0 fallback block self-heals a missing or stale file.
* shell's `:-`, treating empty as unset). Callers gate to LOCAL sessions whose CLI gets
* the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
* claude-specific; a remote or in-container HOME is not this filesystem) and treat it as
* best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex
* caveat: codex 0.162 runs tool commands in a shared app-server daemon whose
* `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's
* file and its self id is wrong; the skill tells the agent how to notice.
*/
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
@@ -1478,31 +1484,59 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
}
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
await rmdir(skillDir).catch(() => {});
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` if now empty
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
return 'removed';
});
}
/**
* Add or remove the Codeman agent skill in `<case>/.claude/skills/codeman`,
* mirroring `applyStatusLineConfig`'s shape. Gated by the synced `agentSkillEnabled`
* app setting (default OFF); callers gate on Claude mode, since the skill is discovered
* via `.claude/skills/`, which only Claude Code reads.
* Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
* had before `capabilities.agentSkillDir` existed.
*/
export const CLAUDE_AGENT_SKILL_DIR: AgentSkillDir = '.claude/skills';
/**
* The workspace-relative skills directory this CLI scans, i.e. where its sessions get
* the skill, or `null` when they get none. Pure registry data, so no call site
* branches on a CLI id.
*/
export function agentSkillDir(
capabilities: Pick<CliCapabilities, 'agentSkillInjection' | 'agentSkillDir'> | undefined
): AgentSkillDir | null {
if (!capabilities?.agentSkillInjection) return null;
return capabilities.agentSkillDir ?? CLAUDE_AGENT_SKILL_DIR;
}
/** `<casePath>/<dir>/codeman`, the directory the skill's SKILL.md lives in. */
export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string {
return join(casePath, dir, 'codeman');
}
/**
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
*
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
* skip the call), for the statusLine reason: sessions in a repo share one `.claude/`
* dir, so a single create while the setting is off must not yank the skill out from
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
* so a single create while the setting is off must not yank the skill out from
* under other live sessions.
*
* ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately
* no server-side toggle-off sweep (it would have to walk every case, including ones
* with live sessions, and would hit exactly the shared-`.claude/` hazard above), so
* with live sessions, and would hit exactly the shared-workspace hazard above), so
* already-injected copies stay on disk until removed per case with
* `codeman skill uninstall --case <name>`. The `enabled: false` branch here backs that
* CLI and the tests; it has no server call site. Keep the README's Agent Skill note in
* sync if this ever changes.
* `codeman skill uninstall --case <name>`, which removes the Codeman-managed copy from
* every skills directory the registry could have written. The `enabled: false` branch
* here backs that CLI and the tests; it has no server call site. Keep the README's
* Agent Skill note in sync if this ever changes.
*/
export async function applyAgentSkill(casePath: string, enabled: boolean): Promise<AgentSkillApplyResult> {
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
export async function applyAgentSkill(
casePath: string,
enabled: boolean,
dir: AgentSkillDir = CLAUDE_AGENT_SKILL_DIR
): Promise<AgentSkillApplyResult> {
const skillDir = agentSkillDirFor(casePath, dir);
return enabled ? installAgentSkillInto(skillDir) : removeAgentSkillFrom(skillDir);
}
+4 -71
View File
@@ -1667,12 +1667,8 @@ class CodemanApp {
* side-effect-light. Calling it again for an already-open window just raises
* that window.
* @param {string} id session id
* @param {{width?: number, height?: number, left?: number, top?: number}} [placement]
* the new window's size and screen position (a detached tile opens its own
* size, where it was let go); omitted, the default 960x680 wherever the
* browser puts it. A host window ignores it.
*/
detachSession(id, placement = null) {
detachSession(id) {
if (this.isSoloWindow) return; // a solo window can't spawn more
if (!this.sessions.has(id)) return;
// Already detached → raise the existing popup instead of opening (or
@@ -1700,7 +1696,7 @@ class CodemanApp {
this._postWindowMessage({ type: 'detached', id });
return;
}
const features = this._detachWindowFeatures(placement);
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
if (!win) {
@@ -1714,21 +1710,6 @@ class CodemanApp {
try { win.focus(); } catch {}
}
/**
* The window.open features of a pop-out: `placement`'s size (default
* 960x680) and, when it has one, its screen position. Only finite numbers
* make it into the string.
*/
_detachWindowFeatures(placement) {
const int = (v) => (Number.isFinite(v) ? Math.round(v) : null);
const width = int(placement?.width) ?? 960;
const height = int(placement?.height) ?? 680;
const left = int(placement?.left);
const top = int(placement?.top);
const at = left !== null && top !== null ? `,left=${left},top=${top}` : '';
return `width=${width},height=${height}${at},menubar=no,toolbar=no,location=no,status=no`;
}
/**
* The embedding app's window opener, when there is one. A native wrapper
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
@@ -1897,15 +1878,11 @@ class CodemanApp {
if (!msg || typeof msg !== 'object') return;
if (this.isSoloWindow) {
// Roll-call has no id (broadcast to all) — answer before the id filter.
// A window that has handed its session back stays silent.
if (msg.type === 'roll-call') {
if (!this._soloReleased) this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
return;
}
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
if (msg.id !== this.soloSessionId) return;
// A host window ignores window.close()/focus() from script it did not
// open by window.open, so ask the host when it offers the call.
if (msg.type === 'close-request') { this._releaseSoloWindow(); }
if (msg.type === 'close-request') { this._closeSoloWindow(); }
else if (msg.type === 'focus-request') {
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
}
@@ -1913,10 +1890,6 @@ class CodemanApp {
}
// Dashboard side.
if (msg.type === 'detached' && msg.id) {
// A pop-out this window just docked as a tile (Detach Tiles), answering
// a roll-call on its way out: not a new pop-out, which would take the
// tile straight back off.
if (this._tileDockedRecently?.(msg.id)) return;
this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock
this._detachPingPending?.delete(msg.id); // and proves liveness for this tick
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
@@ -1926,9 +1899,6 @@ class CodemanApp {
} else if (msg.type === 'detach-request' && msg.id) {
// Future gesture hook: another window asks the dashboard to detach a tab.
this.detachSession(msg.id);
} else if (msg.type === 'tile-adopted' && msg.id && msg.by !== this._wsTabNonce) {
// Another window docked a tile dragged out of this one (tile-grid.js).
this._onTileAdoptedElsewhere?.(msg.id);
}
}
@@ -1975,41 +1945,6 @@ class CodemanApp {
} catch {}
}
/**
* Solo window: a dashboard took the session back (its re-dock, or a
* dashboard docked it as a tile). Says so at once and stops answering
* roll-calls, then closes. A window the browser will not let a script
* close (one opened by typing its URL) says where the session went
* instead, rather than staying a live pop-out that every dashboard would
* mark detached again at its next roll-call.
*/
_releaseSoloWindow() {
if (this._soloReleased) return;
this._soloReleased = true;
this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
this._closeSoloWindow();
setTimeout(() => {
if (!window.closed) this._showSoloReleased();
}, 300);
}
/** Solo window: the session went back to a dashboard and this window could not close. */
_showSoloReleased() {
if (document.querySelector('.solo-gone-overlay')) return;
const el = document.createElement('div');
el.className = 'solo-gone-overlay';
const title = document.createElement('h2');
title.textContent = 'Session moved';
const text = document.createElement('p');
text.textContent = 'This session is back in a Codeman window. This one can be closed.';
const btn = document.createElement('button');
btn.className = 'btn-primary';
btn.textContent = 'Close window';
btn.addEventListener('click', () => this._closeSoloWindow());
el.append(title, text, btn);
document.body.appendChild(el);
}
/** Solo window: select the target session and apply minimal single-session
* chrome. Called from handleInit once the session list has loaded. */
_applySoloMode() {
@@ -2025,8 +1960,6 @@ class CodemanApp {
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
const redock = document.getElementById('soloRedockBtn');
if (redock) redock.style.display = '';
// Detach Tiles: the title drags onto a dashboard's tiles to dock there.
this._installSoloTileHandle?.();
document.title = name + ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
// Neutralize the dashboard-only brand click in a solo window.
+6 -112
View File
@@ -4448,7 +4448,6 @@ var GestureController = class {
// packages/gesture-control/src/codeman/entry.ts
var TAB_SELECTOR = ".session-tab";
var TILE_SELECTOR = "#tileGrid .tile";
var PANEL_SELECTOR = ".cg-float";
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
var DOCK_SELECTOR = ".session-tabs";
@@ -4458,8 +4457,6 @@ var FLOAT_W = 640;
var FLOAT_H = 420;
var DETACH_PULL_PX = 70;
var TAP_CANCEL_PX = 45;
var TILE_MOVE_PX = 40;
var TILE_GHOST_W = 280;
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
var GestureBridge = class {
constructor() {
@@ -4536,7 +4533,7 @@ var GestureBridge = class {
await this.gc.start();
this.running = true;
this.button.classList.add("on");
this.status.textContent = "on: pinch a tab, tile, window, or button";
this.status.textContent = "on \u2014 pinch a tab, window, or button";
} catch (err) {
const msg = describeError(err);
this.status.textContent = `failed: ${msg}`;
@@ -4591,30 +4588,6 @@ var GestureBridge = class {
this.status.textContent = "moving window";
return;
}
const tileId = window.app?.tileAtPoint?.(x2, y2) ?? null;
const tileEl = tileId ? this.hitClosest(x2, y2, TILE_SELECTOR) : null;
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
const rect = tileEl.getBoundingClientRect();
const ghost = this.tileGhost(tileEl, rect);
document.body.append(ghost);
tileEl.classList.add("tile--dragging");
this.grabs.set(hand, {
kind: "tile",
id: tileId,
el: tileEl,
ghost,
offsetX: Math.max(0, Math.min(x2 - rect.left, rect.width)),
offsetY: Math.max(0, Math.min(y2 - rect.top, rect.height)),
ox: x2,
oy: y2,
armed: false,
target: null,
out: false
});
this.positionGhost(ghost, x2, y2);
this.status.textContent = "moving tile";
return;
}
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
const id = tab?.dataset.id;
if (tab && id) {
@@ -4626,7 +4599,7 @@ var GestureBridge = class {
ghost.style.height = `${rect.height}px`;
document.body.append(ghost);
tab.classList.add("cg-grabbed");
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false, target: null });
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
this.positionGhost(ghost, x2, y2);
return;
}
@@ -4640,30 +4613,13 @@ var GestureBridge = class {
}
onDrag(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.positionGhost(grab.ghost, x2, y2);
if (!grab.armed && Math.hypot(x2 - grab.ox, y2 - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
if (!grab.armed) return;
const app = window.app;
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
this.setTileTarget(grab, target);
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x2, y2);
grab.out = out;
grab.ghost.classList.toggle("cg-armed", !!target);
grab.ghost.classList.toggle("cg-out", out);
this.status.textContent = target ? target.kind === "tile" ? "release to swap tiles" : "release to move the tile here" : out ? "release to open in a new window" : "moving tile";
return;
}
if (grab?.kind === "tab") {
this.positionGhost(grab.ghost, x2, y2);
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
const target = pulled ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
this.setTileTarget(grab, target);
if (pulled !== grab.armed || targetChanged) {
if (pulled !== grab.armed) {
grab.armed = pulled;
grab.ghost.classList.toggle("cg-armed", pulled);
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
}
return;
}
@@ -4685,40 +4641,16 @@ var GestureBridge = class {
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove("cg-tap-armed");
this.taps.delete(hand);
this.status.textContent = "on: pinch a tab, tile, window, or button";
this.status.textContent = "on \u2014 pinch a tab, window, or button";
}
}
onDrop(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
const app = window.app;
if (!grab.armed) {
this.flash("cancelled");
return;
}
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
if (target) {
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? "moved tile" : "cancelled");
} else if (grab.out && !app?.isOverTileGrid?.(x2, y2)) {
const opened = app?.detachTileAtPoint?.(grab.id, x2, y2, { offsetX: grab.offsetX, offsetY: grab.offsetY });
this.flash(opened ? "opened in a new window" : "pop-out blocked: allow popups for this site");
} else {
this.flash("cancelled");
}
return;
}
if (grab?.kind === "tab") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
this.setTileTarget(grab, null);
const target = grab.armed ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? "tiled" : "cancelled");
else if (grab.armed) this.floatPanel(grab.id, x2, y2);
if (grab.armed) this.floatPanel(grab.id, x2, y2);
else this.flash("cancelled");
return;
}
@@ -4843,41 +4775,11 @@ var GestureBridge = class {
ghost.style.left = `${x2}px`;
ghost.style.top = `${y2}px`;
}
/** A small copy of a tile to follow the hand: its header (a copy: no
* listeners come along) over an empty body, in the tile's proportions. */
tileGhost(tileEl, rect) {
const ghost = el("div", "cg-ghost cg-tile-ghost");
const width = Math.min(TILE_GHOST_W, rect.width);
ghost.style.width = `${width}px`;
ghost.style.height = `${Math.max(48, Math.round(width * rect.height / Math.max(1, rect.width)))}px`;
const header = tileEl.querySelector(".tile-header");
if (header) {
const copy = header.cloneNode(true);
copy.removeAttribute("draggable");
ghost.append(copy);
}
ghost.append(el("div", "cg-tile-ghost-body"));
return ghost;
}
/** Highlights the tile or empty cell a carried session would drop onto
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
setTileTarget(grab, target) {
if (grab.target?.el !== target?.el) {
grab.target?.el.classList.remove("tile--drop-target");
target?.el.classList.add("tile--drop-target");
}
grab.target = target;
}
cancelAllGrabs() {
for (const grab of this.grabs.values()) {
if (grab.kind === "tab") {
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
this.setTileTarget(grab, null);
} else if (grab.kind === "tile") {
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
} else if (grab.kind === "panel") {
grab.panel.el.style.pointerEvents = "";
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
@@ -4889,7 +4791,6 @@ var GestureBridge = class {
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
this.taps.clear();
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t2) => t2.classList.remove("tile--dragging"));
}
onStatus(fps, hands) {
const { width, height } = this.canvas;
@@ -4962,13 +4863,6 @@ function injectStyles() {
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
.cg-tile-ghost {
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
}
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
.cg-dock {
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
+3 -15
View File
@@ -139,8 +139,6 @@
Drag: '拖动',
"a tile's header": '窗格的标题栏',
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
"a tile's header out of the window": '窗格的标题栏到窗口之外',
'Open the Tile in Its Own Window (Detach Tiles)': '在独立窗口中打开窗格(分离窗格)',
'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
@@ -168,11 +166,6 @@
'Failed to restore terminal size': '恢复终端尺寸失败',
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
'Drag to move the tile': '拖动可移动窗格',
// The same with Detach Tiles on (tile-grid.js _paintTileHandle), and a
// pop-out window's title as a handle back onto the tiles.
'Drag to move the tile, or out of the window to open it on its own': '拖动可移动窗格,拖出窗口可在独立窗口中打开',
'Drag out of the window to open the tile on its own': '拖出窗口可在独立窗口中打开此窗格',
"Drag onto a Codeman window's tiles to dock this session there": '拖到 {name} 窗口的窗格上,即可将此会话停靠到那里',
'Resize tile columns': '调整窗格列宽',
'Resize tile rows': '调整窗格行高',
Attach: '附加',
@@ -221,9 +214,6 @@
'Close window': '关闭窗口',
'Session unavailable': '会话不可用',
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
// A pop-out whose session a dashboard took back, when it cannot close itself (app.js _showSoloReleased).
'Session moved': '会话已移走',
'This session is back in a Codeman window. This one can be closed.': '此会话已回到 {name} 窗口中。可以关闭此窗口。',
// Welcome / quick start / common actions
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
@@ -415,9 +405,6 @@
'CLI Logos on Tabs': '标签页上的 CLI 图标',
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
'Detach Tiles': '分离窗格',
"Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.":
'将窗格的标题栏拖出浏览器,即可在独立窗口中打开它;拖到另一个 {name} 窗口的窗格上,即可将它移到那里。弹出窗口的标题也可以拖回窗格中。',
// Tab Layout and Header Stats Style (Discussion #426). The header style's
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
// button), and "Tiles (label over value)" must not read as the grid.
@@ -1293,8 +1280,9 @@
// The same while tiles can move, with the drag hint on a second line.
// Anchored on the hint, so a bare state word is safe here.
[
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\n(Drag to move the tile|Drag to move the tile, or out of the window to open it on its own|Drag out of the window to open the tile on its own)$/,
(_m, state, duration, hint) => `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN[hint]}`,
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
(_m, state, duration) =>
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
],
];
for (const [pattern, replacement] of patterns) {
+13 -15
View File
@@ -860,7 +860,6 @@
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
<div><kbd>Drag</kbd> a tile's header out of the window</div><div>Open the Tile in Its Own Window (Detach Tiles)</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
@@ -2234,13 +2233,6 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
<div class="set-row-text">
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
<span class="set-row-desc">Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTileDetach"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
<div class="set-row-text">
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
@@ -2605,6 +2597,19 @@
</div>
</div>
<div class="set-group" id="agentSkillGroup">
<div class="set-group-head"><h4>All agent CLIs</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="agent skill codeman api workers codex gemini opencode pi grok deepseek omp antigravity">
<div class="set-row-text">
<span class="set-row-label">Codeman Agent Skill</span>
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
@@ -2641,13 +2646,6 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentTeams"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="agent skill codeman api workers">
<div class="set-row-text">
<span class="set-row-label">Codeman Agent Skill</span>
<span class="set-row-desc">Let new sessions start workers, send prompts and wait for results over the API.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
<div class="set-row-text">
<span class="set-row-label">Workspace Hooks</span>
-12
View File
@@ -510,11 +510,6 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowTabDetachButton').checked =
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
const tileDetachItem = document.getElementById('appSettingsTileDetachItem');
if (tileDetachItem) tileDetachItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
@@ -2621,7 +2616,6 @@ Object.assign(CodemanApp.prototype, {
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
document.getElementById('appSettingsSessionSidebarFontSize').value
@@ -2873,9 +2867,6 @@ Object.assign(CodemanApp.prototype, {
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
showTabDetachButton: _tdb,
// Detach Tiles: what a tile drag does is a property of this device's
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
tileDetachEnabled: _tde,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
// Desktop-only tab decoration, per-device, and likewise absent from the
@@ -3911,8 +3902,6 @@ Object.assign(CodemanApp.prototype, {
// Tiles button: same gate and backstop as Split (tile-grid.js).
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
this._applyTileGridButtonVisibility?.(showTileGridButton);
// Detach Tiles changes what a tile's header does (and says): repaint the handles.
this._renderTileChrome?.();
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
@@ -4369,7 +4358,6 @@ Object.assign(CodemanApp.prototype, {
'sessionLineageLines',
'showSplitButton',
'showTileGridButton',
'tileDetachEnabled',
]);
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
-8
View File
@@ -2990,14 +2990,6 @@ body.solo-mode .btn-lifecycle-log {
overflow: hidden;
text-overflow: ellipsis;
}
/* Detach Tiles: the title is a handle, dragged onto a Codeman window's tiles
to dock the session there (tile-grid.js _installSoloTileHandle). */
.solo-session-title.solo-session-title--handle {
cursor: grab;
}
.solo-session-title.solo-session-title--handle:active {
cursor: grabbing;
}
/* "Session unavailable" overlay for a solo window whose session has ended. */
.solo-gone-overlay {
position: fixed;
+4 -8
View File
@@ -329,14 +329,10 @@ Object.assign(CodemanApp.prototype, {
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// Group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
// A tile (Detach Tiles on) opens its own size, over where it is: the
// keyboard's way to what dragging its header out of the window does.
...(this._tileGrid?.has?.(sessionId) && this._tileDetachAllowed?.()
? [{ label: 'Open in a new window', run: () => this.detachTile(sessionId) }]
: (this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
+14 -420
View File
@@ -56,26 +56,6 @@ const TILE_GRID_REFIT_MS = 150;
// the grid section's padding (styles.css .tile-grid), for the drag math.
const TILE_DIVIDER_PX = 6;
const TILE_GRID_PADDING_PX = 4;
// What a tile's header drag (and a popped-out window's title drag) carries:
// the session id under a type of its own, never text, so no text field or
// terminal takes it as typing. Another Codeman window reads it on drop.
const TILE_DRAG_TYPE = 'application/x-codeman-tile';
// Detach Tiles: a tile dropped outside every window waits this long before it
// opens as a window of its own, so another window that took it (a
// 'tile-adopted' message on the window channel) can still say so first.
const TILE_TEAR_OFF_SETTLE_MS = 120;
// How long after its drag ended a tile can still be claimed by another
// window's drop (the claim and the dragend come from two windows).
const TILE_ADOPT_WINDOW_MS = 5000;
// A session docked here from another window: its old window's 'detached'
// announcements this soon after are stale (it is closing), not a new pop-out.
const TILE_DOCK_GRACE_MS = 2000;
// The smallest window a detached tile opens as, and roughly how much browser
// chrome sits above a pop-out's content (its title and address bars), so the
// window's own header lands near where the tile was let go.
const TILE_WINDOW_MIN_W = 480;
const TILE_WINDOW_MIN_H = 320;
const TILE_WINDOW_CHROME_PX = 60;
/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
function tileGridTracks(fr) {
@@ -1572,10 +1552,8 @@ Object.assign(CodemanApp.prototype, {
* `accepts(id)` is the target's own rule (a tile takes any session but its
* own; an empty cell takes any). A session it does not accept
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
* drop follows). With Detach Tiles on, a tile dragged from ANOTHER Codeman
* window (no drag of this page's own, the tile type in the drag) is taken
* too and docks here (_dockForeignTile). Any other drag (a file) is left to
* the grid section's own guard (_installTileFileDrop).
* drop follows). Any other drag (a file) is left to the grid section's own
* guard (_installTileFileDrop).
*/
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
@@ -1583,16 +1561,7 @@ Object.assign(CodemanApp.prototype, {
'dragover',
(e) => {
const id = dragged();
if (!id) {
// A tile from another Codeman window (Detach Tiles). Its id can
// only be read on drop, so it is held here whatever it is.
if (!this._isForeignTileDrag(e)) return;
e.preventDefault?.();
e.stopPropagation?.();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
el.classList.add('tile--drop-target');
return;
}
if (!id) return;
e.preventDefault?.();
e.stopPropagation?.();
const ok = accepts(id);
@@ -1609,13 +1578,7 @@ Object.assign(CodemanApp.prototype, {
(e) => {
el.classList.remove('tile--drop-target');
const id = dragged();
if (!id) {
if (!this._isForeignTileDrag(e)) return;
e.preventDefault?.();
e.stopPropagation?.();
this._dockForeignTile(e.dataTransfer?.getData?.(TILE_DRAG_TYPE), onDrop);
return;
}
if (!id) return;
e.preventDefault?.();
e.stopPropagation?.();
if (accepts(id)) onDrop(id);
@@ -2117,10 +2080,7 @@ Object.assign(CodemanApp.prototype, {
* input starts no drag. dragstart's target is the header whatever was
* pressed, so where the press landed is noted in the capture phase, ahead of
* the buttons' own stopPropagation. `draggable` is off while moving is
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress),
* unless Detach Tiles is on: then a drop outside the window pops the tile
* out, and a drop on another Codeman window's tiles moves it there (see the
* Detach Tiles section); a rename still turns it off.
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
*/
_installTileMoveDrag(sessionId, header, actions) {
let pressedOnControl = false;
@@ -2134,10 +2094,7 @@ Object.assign(CodemanApp.prototype, {
);
header.addEventListener('dragstart', (e) => {
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
// Detach Tiles lets a tile that cannot move (alone, or zoomed) still be
// dragged, out of the window; no drop target here takes it then.
const detachable = this._tileDetachAllowed();
if (!entry || pressedOnControl || entry.renaming || !(this._tilesMovable() || detachable)) {
if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
e.preventDefault?.();
return;
}
@@ -2145,16 +2102,11 @@ Object.assign(CodemanApp.prototype, {
entry.el.classList.add('tile--dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
// Firefox starts no drag without data. Another Codeman window reads
// the id from it (Detach Tiles).
e.dataTransfer.setData(TILE_DRAG_TYPE, sessionId);
// Firefox starts no drag without data.
e.dataTransfer.setData('application/x-codeman-tile', sessionId);
}
if (detachable) this._beginTileTearOff(sessionId, entry, e);
});
header.addEventListener('dragend', (e) => {
this._settleTileTearOff(sessionId, e);
this._endTileMoveDrag();
});
header.addEventListener('dragend', () => this._endTileMoveDrag());
},
/**
@@ -2165,7 +2117,6 @@ Object.assign(CodemanApp.prototype, {
*/
_endTileMoveDrag() {
this._draggedTileId = null;
this._stopTileTearOffTracking();
const grid = this._tileGrid;
for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
@@ -2173,25 +2124,19 @@ Object.assign(CodemanApp.prototype, {
/**
* The header as a handle: `draggable` while the tile can move (not while a
* tile is zoomed, alone, or being renamed) or, with Detach Tiles on, be
* dragged out of the window (then also alone or zoomed), and its tooltip,
* the state and how long ("working 3m") plus what a drag does. Diffs on the
* last English text, never the DOM (translated in zh-CN; see
* tile is zoomed, alone, or being renamed), and its tooltip, the state and
* how long ("working 3m") plus, while it can move, that it drags. Diffs on
* the last English text, never the DOM (translated in zh-CN; see
* _renderTileOverlay).
*/
_paintTileHandle(entry) {
const movable = this._tilesMovable();
const detachable = this._tileDetachAllowed();
let hint = '';
if (movable && detachable) hint = 'Drag to move the tile, or out of the window to open it on its own';
else if (movable) hint = 'Drag to move the tile';
else if (detachable) hint = 'Drag out of the window to open the tile on its own';
const title = [entry.stateLabel, hint].filter(Boolean).join('\n');
const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
if (entry.headerLabel !== title) {
entry.headerLabel = title;
entry.header.title = title;
}
const draggable = (movable || detachable) && !entry.renaming ? 'true' : 'false';
const draggable = movable && !entry.renaming ? 'true' : 'false';
if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
},
@@ -2914,357 +2859,6 @@ Object.assign(CodemanApp.prototype, {
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
return true;
},
// ── Detach Tiles (per-device `tileDetachEnabled`, default OFF) ───────────
//
// A tile leaves the grid for a window of its own, and comes back into any
// Codeman window's grid, by dragging:
//
// - its header dropped OUTSIDE every window opens it as a pop-out window
// (detachSession, the tab's own "Open in a new window"), at the drop point
// and the tile's size;
// - its header dropped on ANOTHER Codeman window's tile or empty cell moves
// it there: that window docks it and says so on the window channel
// ('tile-adopted'), and this one lets the tile go;
// - a pop-out window's title dropped on a window's tile or empty cell docks
// the session there, and the pop-out closes.
//
// Nothing new on the server: a pop-out is another client of the same session
// (see detachSession), and a tile is a TerminalTile in whichever window has it.
// A session stays in ONE place in this browser, as with every pop-out.
/** Whether Detach Tiles is on for this device. */
tileDetachEnabled() {
return this.loadAppSettingsFromStorage?.()?.tileDetachEnabled === true;
},
/** Detach Tiles applies here: on, and a dashboard (a pop-out has no grid to drag from). */
_tileDetachAllowed() {
return !this.isSoloWindow && this.tileDetachEnabled();
},
/**
* A drag over this page that is a tile from another Codeman window: none of
* this page's own drags is in flight, Detach Tiles is on and the drag
* carries the tile type (its id is only readable on drop).
*/
_isForeignTileDrag(e) {
if (this.draggedTabId || this._draggedTileId || !this._tileGrid?.open || !this._tileDetachAllowed()) return false;
const types = e?.dataTransfer?.types;
return !!types && Array.from(types).includes(TILE_DRAG_TYPE);
},
/**
* A tile from another window dropped on one of this grid's targets: it docks
* through the target's own drop (`place`: a tile it replaces or trades
* places with, an empty cell it fills), and takes focus there (a human
* selection: the user just put it there). A session that was popped out comes
* back first (its window is asked to close). Once it is a tile here, every
* other window hears 'tile-adopted', and the one it was dragged from lets
* its tile go (_onTileAdoptedElsewhere). An id this page does not know (a
* session closed meanwhile, another user's) docks nowhere.
*
* @returns {boolean} whether the session is a tile here afterwards
*/
_dockForeignTile(sessionId, place) {
const grid = this._tileGrid;
if (!grid?.open || typeof sessionId !== 'string' || !this.sessions.has(sessionId)) return false;
const poppedOut = !!this.detachedSessions?.has(sessionId);
// A pop-out window owns its session's place: take it back before mounting
// (a detached session never mounts).
if (poppedOut) this._redock?.(sessionId);
// Already a tile here too (two windows showed it): it moves to where it
// was dropped, as a drag inside this window would move it.
place(sessionId);
if (!grid.has(sessionId)) return false;
(this._tileDockedAt ||= new Map()).set(sessionId, Date.now());
if (poppedOut) this._postWindowMessage?.({ type: 'close-request', id: sessionId });
this._postWindowMessage?.({ type: 'tile-adopted', id: sessionId, by: this._wsTabNonce });
return true;
},
/**
* True for a session this window docked from another one a moment ago: a
* 'detached' announcement from its old pop-out is that window on its way
* out, never a new pop-out (it would take the tile straight back off).
*/
_tileDockedRecently(sessionId) {
const at = this._tileDockedAt?.get(sessionId);
return typeof at === 'number' && Date.now() - at < TILE_DOCK_GRACE_MS;
},
/**
* Another window docked `sessionId` ('tile-adopted'). Only the window it
* was dragged FROM acts: its drag is still settling or ended a moment ago.
* The tile goes without taking the keyboard anywhere (the user is in the
* other window now). The last tile leaving closes the grid onto the welcome
* screen, never onto that session: two windows sizing one PTY garble it.
*/
_onTileAdoptedElsewhere(sessionId) {
const recent = this._lastTileDrag;
const dragging = this._tileTearOff?.id === sessionId;
const pending = this._tileTearOffPending?.id === sessionId;
const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS;
if (!dragging && !pending && !justEnded) return false;
this._cancelTileTearOff();
this._lastTileDrag = null;
const grid = this._tileGrid;
if (!grid?.has(sessionId)) return false;
if (grid.ids.length > 1) return this.removeTile(sessionId, { focus: false });
this.closeTileGrid({ keepStored: false, reselect: false });
this.activeSessionId = null;
try {
localStorage.removeItem('codeman-active-session');
} catch {
/* Nothing stored. */
}
this.terminal?.clear();
this.showWelcome?.();
return true;
},
/**
* A header drag starts with Detach Tiles on: note the tile's size and where
* in it the press landed (the pop-out opens that size, under the pointer),
* and watch whether the pointer leaves the page. Two signals, since
* browsers differ in what dragend reports for a drop outside the window:
* its coordinates, and the page's own dragleave at the viewport's edge
* with nothing entered (cleared again by any dragover inside).
*/
_beginTileTearOff(sessionId, entry, e) {
this._stopTileTearOffTracking();
const rect = entry.el.getBoundingClientRect();
const within = (v, max) => (Number.isFinite(v) ? Math.max(0, Math.min(v, max)) : 0);
const drag = {
id: sessionId,
width: rect.width,
height: rect.height,
offsetX: within(e?.clientX - rect.left, rect.width),
offsetY: within(e?.clientY - rect.top, rect.height),
outside: false,
stop: null,
};
const onOver = () => {
drag.outside = false;
};
const onLeave = (ev) => {
if (ev.relatedTarget) return;
const x = ev.clientX;
const y = ev.clientY;
if (x <= 0 || y <= 0 || x >= window.innerWidth - 1 || y >= window.innerHeight - 1) drag.outside = true;
};
document.addEventListener('dragover', onOver, true);
document.addEventListener('dragleave', onLeave, true);
drag.stop = () => {
document.removeEventListener('dragover', onOver, true);
document.removeEventListener('dragleave', onLeave, true);
};
this._tileTearOff = drag;
},
/** The header drag is over (or the grid or tile went mid-drag): stop watching the pointer. */
_stopTileTearOffTracking() {
const drag = this._tileTearOff;
if (!drag) return;
this._tileTearOff = null;
drag.stop?.();
},
/**
* dragend of a tile's header with Detach Tiles on. Taken by a drop target
* (here or in another window: `dropEffect` is not 'none'), it stays where
* that put it. Let go OUTSIDE the window (the dragend point, or the page's
* own record of the pointer leaving), it opens as a window of its own after
* TILE_TEAR_OFF_SETTLE_MS, unless another window claims it first. Let go
* inside, on nothing, nothing happens, as before.
*/
_settleTileTearOff(sessionId, e) {
const drag = this._tileTearOff?.id === sessionId ? this._tileTearOff : null;
this._lastTileDrag = { id: sessionId, at: Date.now() };
if (!drag) return false;
this._stopTileTearOffTracking();
const effect = e?.dataTransfer?.dropEffect;
if (effect && effect !== 'none') return false;
const cx = e?.clientX;
const cy = e?.clientY;
// (0, 0) is what a browser reports when it does not know where the drop was.
const known = Number.isFinite(cx) && Number.isFinite(cy) && !(cx === 0 && cy === 0);
const outside = known ? cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight : drag.outside;
if (!outside) return false;
const sx = e?.screenX;
const sy = e?.screenY;
const placed = Number.isFinite(sx) && Number.isFinite(sy) && !(sx === 0 && sy === 0);
const placement = {
width: drag.width,
height: drag.height,
...(placed ? { screenX: sx, screenY: sy, offsetX: drag.offsetX, offsetY: drag.offsetY } : {}),
};
this._cancelTileTearOff();
const pending = { id: sessionId, timer: null };
pending.timer = setTimeout(() => {
if (this._tileTearOffPending !== pending) return;
this._tileTearOffPending = null;
this.detachTile(sessionId, placement);
}, TILE_TEAR_OFF_SETTLE_MS);
this._tileTearOffPending = pending;
return true;
},
_cancelTileTearOff() {
const pending = this._tileTearOffPending;
if (!pending) return;
this._tileTearOffPending = null;
clearTimeout(pending.timer);
},
/**
* Opens a tile as a window of its own (detachSession, which takes it out of
* the grid), the tile's size (at least TILE_WINDOW_MIN_W x MIN_H). Placed
* with its header under a screen point when given one (`screenX`/`screenY`
* and where in the tile it was held, `offsetX`/`offsetY`: a drop outside the
* window, a hand let go), otherwise over the tile itself (the ⋯ menu).
*
* @returns {boolean} whether the session is popped out afterwards
*/
detachTile(sessionId, { screenX, screenY, offsetX = 0, offsetY = 0, width, height } = {}) {
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
if (!entry || !this._tileDetachAllowed()) return false;
const rect = entry.el.getBoundingClientRect();
const availW = window.screen?.availWidth || Infinity;
const availH = window.screen?.availHeight || Infinity;
const size = (v, min, max) => Math.round(Math.min(Math.max(Number.isFinite(v) ? v : min, min), max));
const placement = {
width: size(width ?? rect.width, TILE_WINDOW_MIN_W, availW),
height: size(height ?? rect.height, TILE_WINDOW_MIN_H, availH),
};
if (Number.isFinite(screenX) && Number.isFinite(screenY)) {
placement.left = Math.round(screenX - offsetX);
placement.top = Math.round(screenY - offsetY - TILE_WINDOW_CHROME_PX);
} else if (Number.isFinite(window.screenX) && Number.isFinite(window.screenY)) {
// Over the tile: the page's own chrome sits above its viewport.
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
placement.left = Math.round(window.screenX + rect.left);
placement.top = Math.round(window.screenY + chrome + rect.top - TILE_WINDOW_CHROME_PX);
}
this.detachSession?.(sessionId, placement);
return !!this.detachedSessions?.has(sessionId);
},
// ── For the gesture overlay (packages/gesture-control, src/codeman/entry.ts)
//
// A hand is no native drag: the overlay asks where it is and what is under
// it, and every move still goes through the paths a mouse drag takes
// (dropSessionOnTile / dropSessionOnSlot, and so _reorderTiles), the
// pop-out through detachTile.
/** The tiled session whose tile is at viewport point (x, y), or null. */
tileAtPoint(x, y) {
const grid = this._tileGrid;
if (!grid?.open) return null;
const hit = document.elementFromPoint?.(x, y);
if (!hit) return null;
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(hit)) return id;
return null;
},
/** Whether a hand may pick up this tile: it can move, or (Detach Tiles) leave the window. */
canGrabTile(sessionId) {
return !!this._tileGrid?.has(sessionId) && (this._tilesMovable() || this._tileDetachAllowed());
},
/**
* What a session dragged to viewport point (x, y) would drop onto, by the
* mouse drag's own rules: another tile (it takes that tile's place, or the
* two trade places), an empty cell (it moves or joins there). A tiled
* session only while tiles can move (not zoomed). Null for anything else,
* a popped-out session included.
*
* @returns {{kind: 'tile', id: string, el: HTMLElement}|{kind: 'cell', cell: number, el: HTMLElement}|null}
*/
tileDropTargetAt(x, y, draggedId) {
const grid = this._tileGrid;
if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return null;
if (grid.tiles.has(draggedId) && !this._tilesMovable()) return null;
const hit = document.elementFromPoint?.(x, y);
if (!hit) return null;
for (const [id, entry] of grid.tiles) {
if (entry.el.contains?.(hit)) return id === draggedId ? null : { kind: 'tile', id, el: entry.el };
}
for (const slot of grid.slots || []) {
if (slot.contains?.(hit)) return { kind: 'cell', cell: Number(slot.dataset.cell), el: slot };
}
return null;
},
/**
* Drops a session on a target tileDropTargetAt returned (the hand let go).
*
* @returns {boolean} whether the session is a tile afterwards
*/
dropOnTileTarget(draggedId, target) {
if (!target || !this._tileGrid?.open) return false;
if (target.kind === 'tile') this.dropSessionOnTile(draggedId, target.id);
else if (target.kind === 'cell') this.dropSessionOnSlot(draggedId, target.cell);
return !!this._tileGrid?.has(draggedId);
},
/** Whether viewport point (x, y) is over the open grid (a hand let go outside it pops the tile out). */
isOverTileGrid(x, y) {
if (!this._tileGrid?.open) return false;
const rect = document.getElementById('tileGrid')?.getBoundingClientRect?.();
return !!rect && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
},
/**
* A hand let a tile go outside the grid (Detach Tiles): it opens as a window
* of its own with its header near that point. A script opening a window
* with no click behind it is what popup blockers stop; detachSession then
* says so and the tile stays.
*/
detachTileAtPoint(sessionId, x, y, { offsetX = 0, offsetY = 0 } = {}) {
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
const sx = (window.screenX || 0) + x;
const sy = (window.screenY || 0) + chrome + y;
return this.detachTile(sessionId, { screenX: sx, screenY: sy, offsetX, offsetY });
},
/**
* A pop-out window's title, as a handle (Detach Tiles): dragged onto a
* Codeman window's tile or empty cell, the session docks there as a tile
* and this window closes (_dockForeignTile, which asks it to). The same
* drag type as a tile's header; on or off follows the setting, also when it
* changes in another window.
*/
_installSoloTileHandle() {
const title = document.getElementById('soloSessionTitle');
if (!this.isSoloWindow || !title || title._codemanTileHandle) return;
title._codemanTileHandle = true;
const sync = () => {
this._cachedAppSettings = null;
const on = this.tileDetachEnabled();
title.setAttribute('draggable', on ? 'true' : 'false');
title.classList.toggle('solo-session-title--handle', on);
const label = on ? "Drag onto a Codeman window's tiles to dock this session there" : '';
if (title._codemanTileHandleLabel !== label) {
title._codemanTileHandleLabel = label;
title.title = label;
}
};
sync();
window.addEventListener('storage', (e) => {
if (!e.key || e.key === this.getSettingsStorageKey?.()) sync();
});
title.addEventListener('dragstart', (e) => {
if (!this.tileDetachEnabled() || !this.soloSessionId) {
e.preventDefault?.();
return;
}
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(TILE_DRAG_TYPE, this.soloSessionId);
}
});
},
});
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
+2 -2
View File
@@ -41,7 +41,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { Session } from '../../session.js';
import { resolveClaudeModeForUsername } from '../../user-store.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
import { agentSkillDir, applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
import { SseEvent } from '../sse-events.js';
@@ -247,7 +247,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
// skill falls back to writing out the whole ~150-line §0 preamble. Remote and
// docker sessions never reach here (the plan rejects them as
// `remote-or-docker`), so the local-only condition is structural.
if (getCli(session.mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
if (agentSkillDir(getCli(session.mode)?.capabilities) && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
+32 -21
View File
@@ -114,7 +114,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { applyLaunchDefaults } from '../launch-defaults.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
@@ -124,6 +124,8 @@ import {
writeHooksConfig,
updateCaseModel,
stripCaseEnvKeys,
agentSkillDir,
agentSkillDirFor,
applyAgentSkill,
refreshUserAgentSkill,
seedAgentSessionPreamble,
@@ -792,8 +794,8 @@ function abortOnClientHangUp(reply: FastifyReply): AbortController {
* Injection is best-effort and stays that way: neither a refusal nor a thrown error
* may fail the create.
*/
async function injectAgentSkill(casePath: string): Promise<void> {
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
async function injectAgentSkill(casePath: string, dir: AgentSkillDir): Promise<void> {
const skillDir = agentSkillDirFor(casePath, dir);
try {
// Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
// written once by `codeman skill install`) over the case copy injected below, so a
@@ -802,7 +804,7 @@ async function injectAgentSkill(casePath: string): Promise<void> {
// current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
// since a foreign user copy is the user's own authored skill, not a config error.
await refreshUserAgentSkill();
const result = await applyAgentSkill(casePath, true);
const result = await applyAgentSkill(casePath, true, dir);
if (result === 'foreign') {
console.warn(
`[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.`
@@ -1133,13 +1135,6 @@ export function registerSessionRoutes(
// materializing in ~/.claude/settings.local.json was never asked for.
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
// .claude rationale as the statusLine above: a create must never remove the
// skill from under other live sessions in the repo. Marker-guarded, so a
// user's own skills/codeman is never touched.
if (await ctx.getAgentSkillEnabled()) {
await injectAgentSkill(workingDir);
}
}
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
@@ -1163,6 +1158,18 @@ export function registerSessionRoutes(
}
}
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
// workspace rationale as the statusLine above: a create must never remove the
// skill from under other live sessions in the repo. Marker-guarded, so a user's
// own copy is never touched. Every CLI that takes the skill gets it in the directory
// that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir
// conditions as the hooks above, and only once the launch pre-flight passed, so a
// CLI that cannot start leaves nothing in the workspace.
const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities);
if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(workingDir, createSkillDir);
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
// "No conversation found" errors from Claude CLI.
@@ -1274,8 +1281,9 @@ export function registerSessionRoutes(
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
// skill; best-effort.
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
@@ -3870,12 +3878,14 @@ export function registerSessionRoutes(
}
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
// marker-guarded (a user's own skills/codeman is never touched). Claude mode only
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(resolvedCasePath);
// marker-guarded (a user's own copy is never touched). Each CLI that takes the
// skill gets it in the directory it scans (`capabilities.agentSkillDir`:
// `.claude/skills` for claude, `.agents/skills` for most others); skipped for
// remote cases, whose casePath lives on another host. Docker cases qualify:
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
}
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
@@ -4188,8 +4198,9 @@ export function registerSessionRoutes(
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
// skill; best-effort.
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
+196
View File
@@ -0,0 +1,196 @@
/**
* @fileoverview Agent skill per CLI: which workspace directory each CLI's sessions get
* the one packaged skill in (`capabilities.agentSkillDir`), and the
* `codeman skill install|uninstall` resolution that follows the registry.
*
* The skill is read from the REAL packaged source (`skills/codeman/`), so this also
* guards that it meets the Agent Skills format every CLI (and the `npx skills`
* installer) parses: a `name` equal to its directory, lowercase with hyphens, and a
* description of at most 1024 characters.
*
* Pure filesystem and registry tests in a per-test temp dir. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, mkdir, readFile, readdir } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { agentSkillDir, agentSkillDirFor, applyAgentSkill, CLAUDE_AGENT_SKILL_DIR } from '../src/hooks-config.js';
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { registryAgentSkillDirs, resolveSkillInstallDir } from '../src/cli.js';
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
const AGENTS_DIR = '.agents/skills' as const;
/** The frontmatter fields the Agent Skills format defines, with a `>-` folded description joined. */
function frontmatter(skillMd: string): { name?: string; description?: string } {
const match = /^---\n([\s\S]*?)\n---\n/.exec(skillMd);
if (!match) return {};
const lines = match[1].split('\n');
const out: { name?: string; description?: string } = {};
for (let i = 0; i < lines.length; i++) {
const name = /^name:\s*(.+)$/.exec(lines[i]);
if (name) out.name = name[1].trim();
const desc = /^description:\s*(.*)$/.exec(lines[i]);
if (desc) {
if (desc[1].trim() === '>-' || desc[1].trim() === '>') {
const folded: string[] = [];
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) folded.push(lines[++i].trim());
out.description = folded.join(' ');
} else {
out.description = desc[1].trim();
}
}
}
return out;
}
let casePath: string;
beforeEach(async () => {
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-dirs-'));
});
afterEach(async () => {
await rm(casePath, { recursive: true, force: true });
});
describe('the packaged skill meets the Agent Skills format', () => {
it('skills/codeman/SKILL.md: name equals the directory, description within limits', () => {
const fm = frontmatter(readFileSync(join(process.cwd(), 'skills', 'codeman', 'SKILL.md'), 'utf-8'));
expect(fm.name).toBe('codeman');
expect(fm.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
expect(fm.description, 'description').toBeTruthy();
expect(fm.description!.length).toBeLessThanOrEqual(1024);
// It refuses outside Codeman, and says so where every CLI reads first.
expect(fm.description).toContain('CODEMAN_MUX=1');
});
it('is the only packaged skill (one skill for every CLI)', async () => {
expect(await readdir(join(process.cwd(), 'skills'))).toEqual(['codeman']);
});
});
describe('applyAgentSkill with a directory', () => {
it('installs into <case>/.agents/skills/codeman, marker appended, and nothing under .claude', async () => {
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('installed');
const skillDir = join(casePath, '.agents', 'skills', 'codeman');
const skillMd = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(join(casePath, '.claude'))).toBe(false);
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
});
it('removes it again and prunes the emptied skills dir, never the CLI dir above it', async () => {
await applyAgentSkill(casePath, true, AGENTS_DIR);
expect(await applyAgentSkill(casePath, false, AGENTS_DIR)).toBe('removed');
expect(existsSync(join(casePath, '.agents', 'skills'))).toBe(false);
expect(existsSync(join(casePath, '.agents'))).toBe(true);
});
it('two CLIs sharing one workspace each get an identical copy where they look', async () => {
await applyAgentSkill(casePath, true, CLAUDE_AGENT_SKILL_DIR);
await applyAgentSkill(casePath, true, AGENTS_DIR);
const claudeCopy = await readFile(join(casePath, '.claude', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
const agentsCopy = await readFile(join(casePath, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
expect(agentsCopy).toBe(claudeCopy);
});
it("the default directory is still Claude Code's", async () => {
expect(await applyAgentSkill(casePath, true)).toBe('installed');
expect(existsSync(agentSkillDirFor(casePath, CLAUDE_AGENT_SKILL_DIR))).toBe(true);
});
it('writes nothing outside the target directory', async () => {
await mkdir(join(casePath, 'keep'), { recursive: true });
await applyAgentSkill(casePath, true, AGENTS_DIR);
expect((await readdir(casePath)).sort()).toEqual(['.agents', 'keep']);
});
});
describe('agentSkillDir', () => {
it('is null when the CLI takes no skill', () => {
expect(agentSkillDir({ agentSkillInjection: false, agentSkillDir: AGENTS_DIR })).toBeNull();
expect(agentSkillDir(undefined)).toBeNull();
});
it("falls back to Claude Code's directory when only the old boolean is set (pre-field clis.json)", () => {
expect(agentSkillDir({ agentSkillInjection: true })).toBe(CLAUDE_AGENT_SKILL_DIR);
});
it('uses the declared directory otherwise', () => {
expect(agentSkillDir({ agentSkillInjection: true, agentSkillDir: AGENTS_DIR })).toBe(AGENTS_DIR);
});
});
describe('the stock registry', () => {
it('pins the per-CLI table (measured 2026-10-11, see AGENTS_DIR_SKILL in stock.ts)', () => {
const table = Object.fromEntries(
['claude', 'codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'shell'].map((id) => [
id,
agentSkillDir(getCli(id)?.capabilities),
])
);
expect(table).toEqual({
claude: '.claude/skills',
codex: AGENTS_DIR,
gemini: AGENTS_DIR,
opencode: AGENTS_DIR,
antigravity: AGENTS_DIR,
// A project-level skill makes pi ask "Trust project folder?" at startup.
pi: null,
grok: AGENTS_DIR,
deepseek: AGENTS_DIR,
omp: AGENTS_DIR,
shell: null,
});
});
});
describe('capabilities.agentSkillDir validation (clis.json is user-editable)', () => {
const withDir = (dir: unknown) => {
const claude = getCli('claude')!;
return CliEntrySchema.safeParse({ ...claude, capabilities: { ...claude.capabilities, agentSkillDir: dir } });
};
it('accepts a listed directory', () => {
expect(withDir('.agents/skills').success).toBe(true);
});
it('refuses a free path, traversal included', () => {
expect(withDir('../../.ssh').success).toBe(false);
expect(withDir('/etc').success).toBe(false);
expect(withDir('.pi/skills').success).toBe(false);
});
});
describe('codeman skill install|uninstall follow the registry', () => {
it('--mode claude (the default) installs into .claude/skills, user-level or per case', () => {
expect(resolveSkillInstallDir({})).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
expect(resolveSkillInstallDir({ mode: 'claude', case: 'x' })).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
});
it('--mode codex installs per case into .agents/skills, and points a user-level install at npx skills', () => {
expect(resolveSkillInstallDir({ mode: 'codex', case: 'x' })).toEqual({ skillDir: AGENTS_DIR });
expect(resolveSkillInstallDir({ mode: 'codex' }).error).toContain('npx skills add Ark0N/Codeman --skill codeman -g');
});
it('refuses a mode without a skill and an unknown mode', () => {
expect(resolveSkillInstallDir({ mode: 'shell', case: 'x' }).error).toContain('takes no Codeman agent skill');
expect(resolveSkillInstallDir({ mode: 'nope', case: 'x' }).error).toContain('Unknown mode');
});
it('uninstall --case covers every distinct directory the registry can write, once each', () => {
const dirs = registryAgentSkillDirs();
expect(new Set(dirs).size).toBe(dirs.length);
expect(dirs).toContain(CLAUDE_AGENT_SKILL_DIR);
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) expect(dirs, cli.id).toContain(dir);
}
});
});
+7 -5
View File
@@ -132,20 +132,22 @@ describe('linked-cases.json tolerance', () => {
});
describe('skill command wiring', () => {
it('registers install and uninstall, both accepting --case and --global', () => {
it('registers install and uninstall, both accepting --case and --global, install also --mode', () => {
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
expect(skill).toBeDefined();
const subcommands = skill!.commands.map((cmd) => cmd.name());
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
for (const name of ['install', 'uninstall']) {
const flags = skill!.commands
const flagsOf = (name: string) =>
skill!.commands
.find((cmd) => cmd.name() === name)!
.options.map((opt) => opt.long)
.sort();
expect(flags).toEqual(['--case', '--global']);
}
// --mode picks the skill and directory that run mode reads (capabilities.agentSkill);
// uninstall needs none, since --case removes every Codeman-managed copy.
expect(flagsOf('install')).toEqual(['--case', '--global', '--mode']);
expect(flagsOf('uninstall')).toEqual(['--case', '--global']);
});
});
+56
View File
@@ -0,0 +1,56 @@
/** @fileoverview Guards cron docs against drift from routes, schema, statuses, and UI entry points. */
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { CronJobSchema } from '../src/web/schemas.js';
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const reference = read('docs/api-reference.md');
const wiki = read('docs/wiki/HTTP-API.md');
const guide = read('docs/wiki/Cron-Jobs.md');
const cronSection = (text: string) => text.split('## Cron jobs\n')[1]?.split('\n## ')[0] ?? '';
describe('cron documentation', () => {
it('documents every cron route in both API references', () => {
const routes = [...read('src/web/routes/cron-routes.ts').matchAll(/app\.(get|post|put|delete)\('([^']+)'/g)];
expect(routes).toHaveLength(9);
for (const [, method, path] of routes) {
const row = `| ${method.toUpperCase()} | \`${path.replace('/api/', '/api/v1/')}\` |`;
expect(cronSection(reference)).toContain(row);
expect(cronSection(wiki)).toContain(row);
}
});
it('documents every accepted job field and validates the guide example', () => {
for (const field of Object.keys(CronJobSchema.shape)) {
expect(cronSection(reference)).toContain(`| \`${field}\` |`);
}
const example = guide.match(/-d '(\{[\s\S]*?\})'/)?.[1];
expect(example).toBeDefined();
expect(CronJobSchema.safeParse(JSON.parse(example!)).success).toBe(true);
});
it('documents all run statuses without treating prompt delivery as task success', () => {
const statusType = read('src/types/cron.ts').match(/export type CronJobRunStatus = ([^;]+);/)?.[1];
expect(statusType).toBeDefined();
for (const [, status] of statusType!.matchAll(/'([^']+)'/g)) {
expect(guide).toContain(`| \`${status}\``);
expect(cronSection(reference)).toContain(`\`${status}\``);
}
expect(guide.replace(/\s+/g, ' ')).toContain("not whether the agent's task succeeded");
expect(guide).toContain('bottom toolbar');
expect(guide).toContain('App Settings → Header & Panels → Scheduling');
});
it('keeps linked pages consistent and explains non-terminal launch history', () => {
expect(read('docs/wiki/The-Dashboard.md')).toMatch(/\| Cron\s*\| Bottom toolbar/);
expect(read('docs/cron-guide.md')).not.toMatch(/Cron\*\* (?:button )?in the header/);
for (const text of [reference, guide]) {
const normalized = text.replace(/\s+/g, ' ');
expect(normalized).toContain('session is closed during the readiness wait');
expect(normalized).toContain('server restarts before delivery');
expect(normalized).toContain('`session_started` indefinitely with `finishedAt: null`');
}
expect(reference).toContain("also closes the previous run's session before launching");
});
});
-4
View File
@@ -213,8 +213,6 @@ export const bySelector = new Map<string, FakeEl>();
export const body = new FakeEl();
/** The context's `fetch`; a test sets what it answers. */
export const fetchSpy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => ({}) }));
/** What `document.elementFromPoint` answers (the gesture overlay's hit test); a test sets it. */
export const pointHits: { at: ((x: number, y: number) => FakeEl | null) | null } = { at: null };
/** `document.addEventListener`, so a test can find a listener the app installed. */
export const documentAddEventListener = vi.fn();
export const documentRemoveEventListener = vi.fn();
@@ -282,7 +280,6 @@ const context = vm.createContext({
return focusedEl;
},
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
elementFromPoint: (x: number, y: number) => pointHits.at?.(x, y) ?? null,
body,
querySelector: (sel: string) =>
bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
@@ -392,5 +389,4 @@ export function resetGridHarness() {
main.appendChild(section);
bySelector.clear();
body.children = [];
pointHits.at = null;
}
+63 -2
View File
@@ -10,16 +10,25 @@
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { resolveCliLaunchError } from '../../src/utils/cli-launcher.js';
// External CLIs are pre-flighted (is the binary there?) before anything touches the
// workspace; CI has none installed, so the pre-flight is stubbed to "can start" and one
// test flips it to a refusal.
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
@@ -104,6 +113,58 @@ describe('POST /api/sessions agent-skill injection', () => {
expect(existsSync(skillDir())).toBe(false);
});
it('gives a codex session the skill in .agents/skills, where codex looks, and none in .claude', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex', mode: 'codex', workingDir },
});
expect(res.statusCode).toBe(200);
const skillMd = await readFile(join(workingDir, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(workingDir, '.agents', 'skills', 'codeman', 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(skillDir())).toBe(false);
// The preamble cache is seeded for it too, so the skill's §0 is the two-line loader
// in codex as in claude (nothing in the preamble is claude-specific).
const sessionId = (JSON.parse(res.body) as { session: { id: string } }).session.id;
const preamble = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), `codeman-agent-${sessionId}.sh`);
expect(existsSync(preamble)).toBe(true);
});
it('gives a pi session nothing: a project skill would put up pi\'s trust prompt', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-pi', mode: 'pi', workingDir },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
expect(existsSync(join(workingDir, '.pi'))).toBe(false);
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
});
it('writes nothing when the CLI cannot start (pre-flight refusal)', async () => {
vi.mocked(resolveCliLaunchError).mockResolvedValueOnce('codex not found');
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex-missing', mode: 'codex', workingDir },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
});
it('defaults an omitted mode to claude and injects', async () => {
// The frontend omits `mode` for a plain claude create, so the default branch
// is the common path, not an edge case.
-564
View File
@@ -1,564 +0,0 @@
/**
* @fileoverview Detach Tiles (per-device `tileDetachEnabled`, default OFF): a
* tile leaves the grid for a window of its own, and comes back into any
* Codeman window's grid, by dragging.
*
* - OFF (the default) changes nothing: a lone tile's header does not drag, a
* header let go outside the window opens nothing, and a tile dragged from
* another window is not taken.
* - A header let go OUTSIDE the window (dragend with no drop, its point off the
* viewport, or the page's own record of the pointer leaving when the
* browser reports no point) opens the session as a pop-out after a short
* settle, the tile's size, its header under the drop point. Let go inside
* on nothing, or taken by a drop target (`dropEffect: 'move'`), nothing
* opens. A lone or zoomed tile drags too (out only).
* - Another window docking the tile ('tile-adopted' on the window channel)
* takes it out of THIS grid only when this window dragged it (still
* settling, or ended a moment ago); the keyboard stays put, and the last
* tile leaves for the welcome screen, never for that session.
* - A tile from another window dropped on a tile or an empty cell docks there
* through the target's own drop and says so; a popped-out session is taken
* back first and its window asked to close; its stale 'detached' answers
* are ignored for a moment. Unknown ids dock nowhere.
* - The pop-out window: its features carry the placement (the default stays
* 960x680, unplaced); a released pop-out stops answering roll-calls.
* - The gesture overlay's helpers ask the same rules: what is under a point,
* what a drop there would do, and the pop-out at a hand's point.
* - A tile's ⋯ menu offers "Open in a new window" with Detach Tiles on (the
* keyboard's way to the drag), placed over the tile.
* - The setting is per-device: a display key, stripped from the settings PUT,
* absent from the .strict() schema, OFF unless switched on, its row shown on
* desktop only.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
documentAddEventListener,
documentRemoveEventListener,
makeGridApp,
pointHits,
resetGridHarness,
section,
type GridApp,
tileEl,
windowStub,
} from './mocks/tile-grid-vm.js';
const TILE_TYPE = 'application/x-codeman-tile';
const FOUR = ['s-a', 's-b', 's-c', 's-d'];
const THREE = ['s-a', 's-b', 's-c'];
// Five tiles lay out 3x2 here: one empty cell, the last.
const FIVE = ['s-a', 's-b', 's-c', 's-d', 's-e'];
const headerOf = (id: string) => tileEl(id).children[0];
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
const settle = () => new Promise((r) => setTimeout(r, 200));
function dragEvent(extra: Record<string, unknown> = {}, transfer: Record<string, unknown> = {}) {
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 100,
clientY: 10,
...extra,
dataTransfer: { effectAllowed: '', dropEffect: 'none', setData: vi.fn(), ...transfer },
};
}
/** A drag from another window: none of this page's own, the tile type carried, its id readable on drop. */
function foreign(id: string) {
return dragEvent({}, { types: [TILE_TYPE], getData: (t: string) => (t === TILE_TYPE ? id : '') });
}
function openGrid(ids: string[], { detach = true, focusedId = ids[0] } = {}): GridApp {
const app = makeGridApp(ids);
app.loadAppSettingsFromStorage = () => ({ tileDetachEnabled: detach });
app._postWindowMessage = vi.fn();
app._wsTabNonce = 'c-here';
app.openTileGrid(ids, { focusedId });
return app;
}
/** Press the header and start its native drag (a press at (40, 10) inside the tile). */
function startDrag(id: string) {
headerOf(id).dispatch('pointerdown', { target: headerOf(id), button: 0 });
const start = dragEvent({ clientX: 40, clientY: 10 });
headerOf(id).dispatch('dragstart', start);
return start;
}
const endDrag = (id: string, extra: Record<string, unknown>, effect = 'none') =>
headerOf(id).dispatch('dragend', dragEvent(extra, { dropEffect: effect }));
/** The page-level drag listeners the tear-off installed (capture phase). */
const docListener = (type: string) =>
documentAddEventListener.mock.calls.filter((c) => c[0] === type).at(-1)?.[1] as (e: unknown) => void;
beforeEach(() => {
resetGridHarness();
documentAddEventListener.mockClear();
});
afterEach(() => {
delete windowStub.screenX;
delete windowStub.screenY;
delete windowStub.outerHeight;
delete windowStub.screen;
delete windowStub.open;
});
describe('Detach Tiles OFF (the default) changes nothing', () => {
it('a lone tile does not drag, and a header let go outside the window opens nothing', async () => {
const app = openGrid(['s-a'], { detach: false });
app.detachSession = vi.fn();
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
const four = openGrid(FOUR, { detach: false });
four.detachSession = vi.fn();
startDrag('s-b');
endDrag('s-b', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 });
await settle();
expect(four.detachSession).not.toHaveBeenCalled();
expect(four._tileGrid.ids).toEqual(FOUR);
});
it('a tile dragged from another window is not taken', () => {
const app = openGrid(THREE, { detach: false });
const over = foreign('s-other');
tileEl('s-b').dispatch('dragover', over);
expect(over.preventDefault).not.toHaveBeenCalled();
tileEl('s-b').dispatch('drop', foreign('s-other'));
expect(app._tileGrid.ids).toEqual(THREE);
expect(app._postWindowMessage).not.toHaveBeenCalled();
});
it('the header tooltip keeps its old wording', () => {
openGrid(THREE, { detach: false });
expect(headerOf('s-a').title).toContain('Drag to move the tile');
expect(headerOf('s-a').title).not.toContain('window');
});
});
describe('dragging a header out of the window', () => {
it('opens the session as a pop-out after a short settle, the tile size, its header under the drop point', async () => {
const app = openGrid(FOUR);
app.detachSession = vi.fn();
startDrag('s-b');
endDrag('s-b', { clientX: -50, clientY: 300, screenX: 900, screenY: 500 });
expect(app.detachSession).not.toHaveBeenCalled();
await settle();
expect(app.detachSession).toHaveBeenCalledTimes(1);
const [id, placement] = app.detachSession.mock.calls[0];
expect(id).toBe('s-b');
// The harness's tiles measure 2400x1200; the press was at (40, 10).
expect(placement).toEqual({ width: 2400, height: 1200, left: 860, top: 430 });
});
it('a lone tile, or a zoomed one, drags out too (no target inside takes it)', async () => {
const app = openGrid(['s-a']);
app.detachSession = vi.fn();
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
expect(headerOf('s-a').title).toContain('Drag out of the window to open the tile on its own');
expect(startDrag('s-a').preventDefault).not.toHaveBeenCalled();
endDrag('s-a', { clientX: 5000, clientY: 300, screenX: 5000, screenY: 300 });
await settle();
expect(app.detachSession).toHaveBeenCalledWith('s-a', expect.any(Object));
const zoomed = openGrid(FOUR);
zoomed.zoomTile('s-c');
expect(headerOf('s-c').getAttribute('draggable')).toBe('true');
});
it('let go inside the window on nothing, or taken by a drop target: nothing opens', async () => {
const app = openGrid(FOUR);
app.detachSession = vi.fn();
startDrag('s-b');
endDrag('s-b', { clientX: 300, clientY: 300, screenX: 300, screenY: 400 });
startDrag('s-c');
endDrag('s-c', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 }, 'move');
await settle();
expect(app.detachSession).not.toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(FOUR);
});
it('no point reported: the page record of the pointer leaving at the edge decides, a dragover inside undoes it', async () => {
const app = openGrid(FOUR);
app.detachSession = vi.fn();
startDrag('s-b');
docListener('dragleave')({ relatedTarget: null, clientX: 0, clientY: 500 });
endDrag('s-b', { clientX: 0, clientY: 0, screenX: 0, screenY: 0 });
await settle();
expect(app.detachSession).toHaveBeenCalledTimes(1);
// No screen point: placed over the tile instead (no window position known here: size only).
expect(app.detachSession.mock.calls[0][1]).toEqual({ width: 2400, height: 1200 });
app.detachSession.mockClear();
startDrag('s-c');
docListener('dragleave')({ relatedTarget: null, clientX: 0, clientY: 500 });
docListener('dragover')({});
endDrag('s-c', { clientX: 0, clientY: 0, screenX: 0, screenY: 0 });
await settle();
expect(app.detachSession).not.toHaveBeenCalled();
});
it('the page drag listeners go when the drag ends, and when the grid closes mid-drag', () => {
const app = openGrid(FOUR);
startDrag('s-b');
const over = docListener('dragover');
const leave = docListener('dragleave');
documentRemoveEventListener.mockClear();
endDrag('s-b', { clientX: 300, clientY: 300 });
expect(documentRemoveEventListener).toHaveBeenCalledWith('dragover', over, true);
expect(documentRemoveEventListener).toHaveBeenCalledWith('dragleave', leave, true);
startDrag('s-c');
const over2 = docListener('dragover');
documentRemoveEventListener.mockClear();
app.closeTileGrid({ reselect: false });
expect(documentRemoveEventListener).toHaveBeenCalledWith('dragover', over2, true);
expect(app._tileTearOff).toBeNull();
});
it('the real pop-out takes the tile out of the grid (as the tab pop-out does)', async () => {
const app = openGrid(FOUR);
const win = { closed: false, focus: vi.fn() };
windowStub.open = vi.fn(() => win);
app.detachedWindows = new Map();
app._detachWatchTimers = new Map();
app.hasHostWindows = () => false;
// No tab strip in this harness.
app.$ = () => null;
startDrag('s-b');
endDrag('s-b', { clientX: -50, clientY: 300, screenX: 900, screenY: 500 });
await settle();
expect(windowStub.open).toHaveBeenCalledWith(
'/session/s-b',
'codeman-session-s-b',
'width=2400,height=1200,left=860,top=430,menubar=no,toolbar=no,location=no,status=no'
);
expect(app.detachedSessions.has('s-b')).toBe(true);
expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']);
});
});
describe('another window docking the tile this one dragged', () => {
it('a drop target in another window took it: the tile goes, the keyboard stays put', () => {
const app = openGrid(FOUR, { focusedId: 's-b' });
startDrag('s-b');
endDrag('s-b', { clientX: -50, clientY: 300 }, 'move');
const focusBefore = FakeTile.all.map((t) => t.terminal.focus.mock.calls.length);
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' });
expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']);
expect(app._tileGrid.focusedId).not.toBe('s-b');
expect(FakeTile.all.map((t) => t.terminal.focus.mock.calls.length)).toEqual(focusBefore);
});
it('a claim that arrives while the pop-out is settling cancels the pop-out', async () => {
const app = openGrid(FOUR);
app.detachSession = vi.fn();
startDrag('s-c');
endDrag('s-c', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 });
app._onWindowMessage({ type: 'tile-adopted', id: 's-c', by: 'c-there' });
await settle();
expect(app.detachSession).not.toHaveBeenCalled();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-d']);
});
it('the last tile leaves for the welcome screen, never for that session', () => {
const app = openGrid(['s-a']);
startDrag('s-a');
endDrag('s-a', { clientX: -50, clientY: 300 }, 'move');
app._onWindowMessage({ type: 'tile-adopted', id: 's-a', by: 'c-there' });
expect(app._tileGrid.open).toBe(false);
expect(app.activeSessionId).toBeNull();
expect(app.showWelcome).toHaveBeenCalled();
expect(app.terminal.clear).toHaveBeenCalled();
});
it('a claim for a tile this window did not drag, or one it dragged long ago, is ignored', () => {
const app = openGrid(FOUR);
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' });
expect(app._tileGrid.ids).toEqual(FOUR);
startDrag('s-b');
endDrag('s-b', { clientX: 300, clientY: 300 }, 'move');
app._lastTileDrag.at -= 60_000;
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' });
expect(app._tileGrid.ids).toEqual(FOUR);
});
it("this window's own claim (same nonce) is never acted on", () => {
const app = openGrid(FOUR);
startDrag('s-b');
endDrag('s-b', { clientX: 300, clientY: 300 }, 'move');
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-here' });
expect(app._tileGrid.ids).toEqual(FOUR);
});
});
describe('a tile from another window dropped here', () => {
it('on a tile: it takes that tile’s place, focused, and every window hears it was docked', () => {
const app = openGrid(THREE);
const over = foreign('s-other');
tileEl('s-b').dispatch('dragover', over);
expect(over.preventDefault).toHaveBeenCalled();
expect(over.stopPropagation).toHaveBeenCalled();
expect(over.dataTransfer.dropEffect).toBe('move');
expect(tileEl('s-b').classList.contains('tile--drop-target')).toBe(true);
tileEl('s-b').dispatch('drop', foreign('s-other'));
expect(app._tileGrid.ids).toEqual(['s-a', 's-other', 's-c']);
expect(app._tileGrid.focusedId).toBe('s-other');
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' });
expect(tileEl('s-other').classList.contains('tile--drop-target')).toBe(false);
});
it('on an empty cell: it joins there', () => {
const app = openGrid(FIVE);
expect(slots()).toHaveLength(1);
slots()[0].dispatch('drop', foreign('s-other'));
expect(app._tileGrid.cells).toEqual([...FIVE, 's-other']);
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' });
});
it('a popped-out session is taken back first and its window asked to close; its stale answers are ignored', () => {
const app = openGrid(THREE);
app.detachedSessions.add('s-other');
app._redock = vi.fn((id: string) => app.detachedSessions.delete(id));
app._markDetached = vi.fn();
tileEl('s-c').dispatch('drop', foreign('s-other'));
expect(app._redock).toHaveBeenCalledWith('s-other');
expect(app._tileGrid.ids).toContain('s-other');
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'close-request', id: 's-other' });
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' });
// The closing pop-out answering a roll-call on its way out.
app._onWindowMessage({ type: 'detached', id: 's-other' });
expect(app._markDetached).not.toHaveBeenCalled();
expect(app._tileGrid.ids).toContain('s-other');
});
it('an id this page does not know docks nowhere', () => {
const app = openGrid(THREE);
tileEl('s-b').dispatch('drop', foreign('s-gone'));
tileEl('s-b').dispatch('drop', foreign(''));
expect(app._tileGrid.ids).toEqual(THREE);
expect(app._postWindowMessage).not.toHaveBeenCalled();
});
it("a drag of this page's own is never read as a foreign one", () => {
const app = openGrid(FOUR);
startDrag('s-a');
const over = foreign('s-other');
over.dataTransfer.types = [TILE_TYPE];
tileEl('s-d').dispatch('drop', over);
// The own drag wins: s-a and s-d trade places, nothing docks.
expect(app._tileGrid.ids).toEqual(['s-d', 's-b', 's-c', 's-a']);
expect(app._postWindowMessage).not.toHaveBeenCalled();
});
});
describe('the pop-out window', () => {
it('features: the placement when given, the old default otherwise', () => {
const app = makeGridApp();
expect(app._detachWindowFeatures(null)).toBe('width=960,height=680,menubar=no,toolbar=no,location=no,status=no');
expect(app._detachWindowFeatures({ width: 700.4, height: 500, left: -1200, top: 40 })).toBe(
'width=700,height=500,left=-1200,top=40,menubar=no,toolbar=no,location=no,status=no'
);
// A position needs both coordinates; non-numbers never reach the string.
expect(app._detachWindowFeatures({ left: 10, top: Number.NaN, width: 'x' })).toBe(
'width=960,height=680,menubar=no,toolbar=no,location=no,status=no'
);
});
it('from the ⋯ menu (no point): over the tile, at least the minimum size, within the screen', () => {
const app = openGrid(FOUR);
app.detachSession = vi.fn();
windowStub.screenX = 100;
windowStub.screenY = 50;
windowStub.outerHeight = 1300;
windowStub.screen = { availWidth: 1920, availHeight: 1080 };
app.detachTile('s-b');
expect(app.detachSession).toHaveBeenCalledWith('s-b', { width: 1920, height: 1080, left: 100, top: 90 });
});
it('never with Detach Tiles off, nor for a session that is not a tile', () => {
const off = openGrid(FOUR, { detach: false });
off.detachSession = vi.fn();
expect(off.detachTile('s-b')).toBe(false);
const on = openGrid(FOUR);
on.detachSession = vi.fn();
expect(on.detachTile('s-other')).toBe(false);
expect(off.detachSession).not.toHaveBeenCalled();
expect(on.detachSession).not.toHaveBeenCalled();
});
it('a released pop-out says so, then stops answering roll-calls', () => {
const app = makeGridApp();
app.isSoloWindow = true;
app.soloSessionId = 's-a';
app._postWindowMessage = vi.fn();
app._closeSoloWindow = vi.fn();
app._onWindowMessage({ type: 'roll-call' });
expect(app._postWindowMessage).toHaveBeenLastCalledWith({ type: 'detached', id: 's-a' });
app._onWindowMessage({ type: 'close-request', id: 's-a' });
expect(app._postWindowMessage).toHaveBeenLastCalledWith({ type: 'redocked', id: 's-a' });
expect(app._closeSoloWindow).toHaveBeenCalled();
app._postWindowMessage.mockClear();
app._onWindowMessage({ type: 'roll-call' });
expect(app._postWindowMessage).not.toHaveBeenCalled();
});
});
describe('the gesture overlay helpers', () => {
it('what is under a point: the tile, and whether a hand may pick it up', () => {
const app = openGrid(FOUR);
const inner = new FakeEl();
tileEl('s-c').children[1].appendChild(inner);
pointHits.at = () => inner;
expect(app.tileAtPoint(10, 10)).toBe('s-c');
expect(app.canGrabTile('s-c')).toBe(true);
pointHits.at = () => null;
expect(app.tileAtPoint(10, 10)).toBeNull();
// Alone with Detach Tiles off it can neither move nor leave.
const lone = openGrid(['s-a'], { detach: false });
expect(lone.canGrabTile('s-a')).toBe(false);
const loneOn = openGrid(['s-a']);
expect(loneOn.canGrabTile('s-a')).toBe(true);
});
it("what a drop there would do, by the mouse drag's rules, and doing it", () => {
const app = openGrid(FIVE);
pointHits.at = () => tileEl('s-c');
expect(app.tileDropTargetAt(1, 1, 's-a')).toEqual({ kind: 'tile', id: 's-c', el: tileEl('s-c') });
// Its own tile is no target; a popped-out or unknown session has none.
expect(app.tileDropTargetAt(1, 1, 's-c')).toBeNull();
app.detachedSessions.add('s-other');
expect(app.tileDropTargetAt(1, 1, 's-other')).toBeNull();
app.detachedSessions.delete('s-other');
expect(app.tileDropTargetAt(1, 1, 's-nope')).toBeNull();
expect(app.dropOnTileTarget('s-a', app.tileDropTargetAt(1, 1, 's-a'))).toBe(true);
expect(app._tileGrid.ids).toEqual(['s-c', 's-b', 's-a', 's-d', 's-e']);
pointHits.at = () => slots()[0];
const cell = app.tileDropTargetAt(1, 1, 's-other');
expect(cell).toEqual({ kind: 'cell', cell: 5, el: slots()[0] });
expect(app.dropOnTileTarget('s-other', cell)).toBe(true);
expect(app._tileGrid.cells).toEqual(['s-c', 's-b', 's-a', 's-d', 's-e', 's-other']);
});
it('a tiled session has no target while a tile is zoomed (moving is off)', () => {
const app = openGrid(FOUR);
app.zoomTile('s-a');
pointHits.at = () => tileEl('s-a');
expect(app.tileDropTargetAt(1, 1, 's-b')).toBeNull();
});
it('a hand let go outside the grid: the pop-out at that point, the page chrome counted', () => {
const app = openGrid(FOUR);
app.detachSession = vi.fn();
windowStub.screenX = 0;
windowStub.screenY = 0;
windowStub.outerHeight = 1300;
app.detachTileAtPoint('s-d', 500, 20, { offsetX: 100, offsetY: 10 });
expect(app.detachSession).toHaveBeenCalledWith('s-d', { width: 2400, height: 1200, left: 400, top: 50 });
});
it('whether a point is over the grid', () => {
const app = openGrid(FOUR);
expect(app.isOverTileGrid(10, 10)).toBe(true);
expect(app.isOverTileGrid(10, 5000)).toBe(false);
app.closeTileGrid({ reselect: false });
expect(app.isOverTileGrid(10, 10)).toBe(false);
});
});
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
describe("a tile's ⋯ menu", () => {
function menuFor(sessionId: string, { tiled = true, detach = true, tabPopOut = false } = {}) {
const dom = new JSDOM('<!doctype html><html><body><button id="t">⋯</button></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
const ctx = dom.getInternalVMContext();
vm.runInContext('globalThis.CodemanApp = class CodemanApp {};', ctx);
vm.runInContext(read('tab-rail-resize.js'), ctx, { filename: 'tab-rail-resize.js' });
const App = (dom.window as unknown as { CodemanApp: { prototype: object } }).CodemanApp;
const app = Object.assign(Object.create(App.prototype), {
loadAppSettingsFromStorage: () => ({ showTabDetachButton: tabPopOut }),
tabDetachButtonEnabled: (s: { showTabDetachButton?: boolean }) => s.showTabDetachButton === true,
detachedSessions: new Set(),
_tileGrid: { has: () => tiled },
_tileDetachAllowed: () => detach,
detachTile: vi.fn(),
detachSession: vi.fn(),
openSessionOptions: vi.fn(),
requestCloseSession: vi.fn(),
}) as Record<string, ReturnType<typeof vi.fn> & ((...a: unknown[]) => unknown)>;
const doc = dom.window.document;
app.openTabRailActionMenu(
{ preventDefault() {}, stopPropagation() {}, currentTarget: doc.getElementById('t') },
sessionId
);
const items = [...doc.querySelectorAll('.tab-rail-action-menu [role="menuitem"]')] as HTMLButtonElement[];
return {
app,
labels: items.map((b) => b.textContent),
click: (label: string) => items.find((b) => b.textContent === label)!.click(),
};
}
it('with Detach Tiles on: "Open in a new window" pops the tile out, placed over it', () => {
const { app, labels, click } = menuFor('s-b');
expect(labels).toEqual(['Session options', 'Open in a new window', 'Close session']);
click('Open in a new window');
expect(app.detachTile).toHaveBeenCalledWith('s-b');
expect(app.detachSession).not.toHaveBeenCalled();
});
it('with it off: the menu is what it was (the tab pop-out setting still adds its own entry)', () => {
expect(menuFor('s-b', { detach: false }).labels).toEqual(['Session options', 'Close session']);
const tabbed = menuFor('s-b', { detach: false, tabPopOut: true });
expect(tabbed.labels).toEqual(['Session options', 'Open in a new window', 'Close session']);
tabbed.click('Open in a new window');
expect(tabbed.app.detachSession).toHaveBeenCalledWith('s-b');
// A tab (not a tile) with Detach Tiles on: only the tab setting decides.
expect(menuFor('s-x', { tiled: false }).labels).toEqual(['Session options', 'Close session']);
});
});
describe('tileDetachEnabled stays per-device and OFF by default', () => {
const settingsUi = read('settings-ui.js');
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
const index = read('index.html');
it('is a display key, stripped from the settings PUT, and absent from the .strict() schema', () => {
const start = settingsUi.indexOf('const displayKeys = new Set([');
expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'tileDetachEnabled'");
expect(settingsUi).toContain('tileDetachEnabled: _tde,');
expect(schemas).not.toContain('tileDetachEnabled');
});
it('loads OFF unless switched on, saves from its switch, and its row shows on desktop only', () => {
expect(settingsUi).toContain(
"document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;"
);
expect(settingsUi).toContain("tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,");
expect(settingsUi).toMatch(/tileDetachItem\.style\.display = MobileDetection\.getDeviceType\(\) === 'desktop'/);
expect(index).toMatch(/id="appSettingsTileDetachItem"[\s\S]*?<input type="checkbox" id="appSettingsTileDetach">/);
// Not `checked` in the markup: OFF is the default.
expect(index).not.toMatch(/id="appSettingsTileDetach" checked/);
});
it('a saved change repaints the tile handles (draggable, tooltip)', () => {
expect(settingsUi).toContain('this._renderTileChrome?.();');
});
});
-49
View File
@@ -217,18 +217,6 @@ async function exercise() {
}
pill = 'idle';
// Detach Tiles on: the drag hint says a tile also leaves the window, and
// a tile that cannot move (zoomed) says only that.
const settings = app.loadAppSettingsFromStorage;
app.loadAppSettingsFromStorage = () => ({ tileDetachEnabled: true });
app._renderTileChrome();
harvestAll(app, 'detach tiles');
app.zoomTile('s-1');
harvestAll(app, 'detach tiles, zoomed');
app.zoomTile('s-1');
app.loadAppSettingsFromStorage = settings;
app._renderTileChrome();
// Zoom and back.
app.zoomTile('s-1');
harvestAll(app, 'zoomed');
@@ -332,8 +320,6 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'idle 3m\nDrag to move the tile',
'needs you 3m\nDrag to move the tile',
'exited 3m\nDrag to move the tile',
'idle 3m\nDrag to move the tile, or out of the window to open it on its own',
'idle 3m\nDrag out of the window to open the tile on its own',
'Not attached',
'Attach',
'Attaching…',
@@ -404,30 +390,6 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
expect(zh.api.t('idle\nDrag to move the tile')).toBe('空闲\n拖动可移动窗格');
expect(zh.api.t('Drag to move the tile')).toBe('拖动可移动窗格');
expect(en.api.t('idle\nDrag to move the tile')).toBe('idle\nDrag to move the tile');
// Detach Tiles' two hints ride the same pattern.
expect(zh.api.t('idle\nDrag out of the window to open the tile on its own')).toBe(
'空闲\n拖出窗口可在独立窗口中打开此窗格'
);
expect(zh.api.t('working <1m\nDrag to move the tile, or out of the window to open it on its own')).toBe(
'工作中 <1m\n拖动可移动窗格,拖出窗口可在独立窗口中打开'
);
});
it("Detach Tiles' pop-out strings: its title as a handle, and a pop-out that could not close", () => {
const strings = [
"Drag onto a Codeman window's tiles to dock this session there",
'Session moved',
'This session is back in a Codeman window. This one can be closed.',
'Close window',
];
// Not vacuous: each is a string the code really shows.
const code = read('app.js') + read('tile-grid.js');
expect(strings.filter((source) => !code.includes(source))).toEqual([]);
const bad = strings.filter((source) => {
const text = zh.api.t(source);
return text === source || leftover(text.replace(/Codeman/g, '')).length > 0 || en.api.t(source) !== source;
});
expect(bad).toEqual([]);
});
it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => {
@@ -496,17 +458,6 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格');
expect(tiles!.textContent).toContain('拖动');
expect(tiles!.textContent).toContain('窗格的标题栏');
// Detach Tiles: a header dragged out of the window.
expect(tiles!.textContent).toContain('窗格的标题栏到窗口之外');
expect(tiles!.textContent).toContain('在独立窗口中打开窗格(分离窗格)');
});
it('the Detach Tiles row in App Settings: its name and what it does', () => {
const row = doc.getElementById('appSettingsTileDetachItem')!;
expect(row.querySelector('.set-row-label')!.textContent).toContain('分离窗格');
const desc = row.querySelector('.set-row-desc')!.textContent!;
expect(desc).toContain('独立窗口');
expect(leftover(desc.replace('Codeman', ''))).toEqual([]);
});
it('user text stays as typed: a session name and a group name that are also UI words', () => {