Compare commits

..
Author SHA1 Message Date
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
Codeman maintainer 3d59e59e37 chore: version packages (1.41.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 07:12:42 +02:00
Codeman maintainer 019118ba9d chore: fold #581's own changeset into the 1.41.0 changeset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 07:12:00 +02:00
Codeman maintainer 7a4d520cd0 chore: changeset entry for the Tiles open order and kept layout
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:49:39 +02:00
Codeman maintainer 08d7fa1985 Merge the Tiles open order and kept layout into the 1.41.0 release branch 2026-10-10 06:49:25 +02:00
Codeman maintainer 0c6cbd0718 docs(tiles): a popped-out session frees its place too
The wiki said a session closed in the meantime frees its place for the
ranking. A session popped out into its own window does the same (it is
never tiled while its window owns its PTY size, and since the previous fix
popping it out with the grid open keeps the count, too), so the sentence now
names both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:38:46 +02:00
Codeman maintainer 8b2cb49940 docs(tiles): a reload brings the grid back only when it was open
The wiki, decision 11 in the tile-grid plan, its Persistence section, the
layout-memory test header and two tile-grid.js comments said the Tiles
button and a page reload bring the grid back however it was closed. Only
the Tiles button does: a grid closed before the reload stays stored as
open: false, the page shows the single view, and the toggle puts the grid
back. The behaviour was right; the wording now says that.

A test pins it through the real close path: arrange the grid, close it with
the toggle, start a fresh page, and the restore leaves the single view
while the toggle still brings back the cells, count, divider sizes, focus
and zoom. ('a stored closed grid leaves the single view' in
tile-grid-restore.test.ts covers the same from a raw stored value through
handleInit.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:33:00 +02:00
Codeman maintainer fb5c1bfca5 docs(tiles): a reload's fill ranks without pending approvals, by design
A page reload puts a stored open grid back inside handleInit and fills a
cell freed since (its session deleted while the grid was closed) from the
ranking. Pending approvals, the source of the needs-input group, reach the
page only through seedApprovals, an async GET that lands after the restore,
so for that one fill a session waiting on a permission dialog or an unseen
finished turn ranks with the quiet ones (working sessions still rank first).

Holding the fill back until the seed lands would open fewer tiles, which
can be another shape, and then reshape the grid and move the user's tiles a
moment after the reload, so a late seed never re-forms a restored grid. The
plan, architecture-invariants#tile-grid and the wiki now say so, and a test
pins it: the seed arrives after the restore, the ranking then puts the
needs-input session first, and the restored cells and the stored layout
stay as they came back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:31:26 +02:00
Codeman maintainer 7b95856cc5 fix(tiles): popping out a tiled session keeps the grid's count
A session popped out while the grid was open (its pop-out button, or
another dashboard tab announcing the pop-out over the window channel) left
the grid through _markDetached, which removed its tile as if the user had
closed it: the stored count dropped to five and its cell stayed a hole the
ranking never filled, so after the window re-docked Tiles off and on showed
five tiles and an empty cell. Popping out with the grid closed, or a reload
reconcile, already kept the count and refilled the cell.

_markDetached now removes the tile as `gone` (it left by itself), the same
as a deleted session or a refused socket, so the count stays and the next
activation fills the cell from the ranking.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:30:08 +02:00
Codeman maintainer 3a40dae1dd test(tiles): pin the reload fill to the init payload's states and tab order
A page reload restores the stored grid inside handleInit; a cell freed by a
session gone since is filled from the ranking. handleInit syncs the tab
order and loads the session states before the restore runs, so the fill
ranks with real data: this test runs the real syncSessionOrder against a
server snapshot order and shows a working session taking the freed cell
ahead of the first tab. Pending approvals are seeded asynchronously after
init, so the needs-input group cannot rank on a reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:06:22 +02:00
Codeman maintainer a81d344fb5 docs(tiles): the ranking and the kept layout
The tile-grid plan records owner decision 11 (the layout is kept per
browser and comes back exactly; a fresh or filling grid ranks working,
then needing input, then most recent) and marks where it supersedes
decision 10's "the count wins over a remembered grid's size". The plan,
architecture-invariants#tile-grid and the CLAUDE.md Tile grid paragraph
now describe the stored format (`count`, freed cells, still v: 1), every
close path keeping it, and the open order; the wiki tells users what the
Tiles button brings back and how a new grid is chosen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 06:01:34 +02:00
Codeman maintainer 16980d644d feat(tiles): keep the arranged layout per browser across toggles and reloads
The Tiles toggle used to fill a remembered grid to the count picked in the
menu (refilling a hole the user made, or trimming tiles), removing the last
tile or "Open group as tiles" forgot it, and Ctrl/Cmd+click repacked it.
The owner asked for the opposite: "when I turn tiles off and on, always
keep what the last setting was".

Now the layout the user arranged (which session sits in which cell, holes
included, the tile count, the divider sizes, the focused tile and a zoom
they chose) is written to localStorage on every change and comes back
exactly from the toggle, Ctrl+Shift+G and a reload, however the grid was
closed (toggle, a non-tiled tab, leaveTiles or a #session= link, Home, the
width gate, the last tile, a group, closing or killing sessions). Nothing
of it reaches the server.

- The stored value stays v: 1 and gains `count`: how many tiles the user's
  own last change left. A session that goes away by itself (deleted,
  popped out, socket refused) does not lower it, so the next activation
  fills that cell from the ranking; a hole the user made stays a hole.
- sanitizeTileGridState reports `freed` cells (sessions gone since) and
  derives `count` for values written before it; restoreTileGridCells (pure)
  puts a stored grid back, freed cells first, never trimmed to the window
  (a too-small window shows the focused tile until it fits).
- Only when none of the stored sessions survive does activation rank from
  scratch. A count picked in the menu still re-forms the grid to it.
- Nothing is persisted while a stored grid is being put back, and a
  #session= link only flips `open`, keeping gone ids for the fill.
- The hover card no longer promises "a click opens N" when a stored grid
  will open instead.

This supersedes decision 10's answer that the count wins over a
remembered grid's size; the affected tests say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:58:17 +02:00
Codeman maintainer b538429f6e chore: changeset entry for the npm README fix
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:47:25 +02:00
Codeman maintainer 7689a43782 fix(release): npmjs.com shows the English README, and the package metadata names what Codeman is
npm takes the package's readme from an unsorted {README,README.*} glob at
publish time, and with README.zh-CN.md beside README.md it kept picking the
Chinese one, so npmjs.com rendered it. `npm run release` now runs
scripts/npm-release.mjs, which moves README.zh-CN.md to a dot-name for the
length of `changeset publish` and always puts it back (also on a failed or
thrown publish, and after an interrupted earlier run). It lives in the publish
command, not in release.yml, because changesets/action commits the working
tree into its version PR. Verified with npm's own normalize: README.zh-CN.md
before, README.md with the file aside, and the aside copy is never packed.

package.json: the description names the CLIs and says self-hosted (the old
"run 20 autonomous agents" was an internal performance target), and homepage
points at getcodeman.com instead of repeating the repository link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:47:15 +02:00
Codeman maintainer 950450b137 feat(tiles): open and fill tiles by work, then input, then recency
The Tiles button with no stored grid opened the first tabs, so the oldest
sessions got tiles while the busy ones were left out. It now ranks the
open sessions: working first (the most recently started turn first, keyed
off lastSubmitAt only, since a working pane's activity stamp is always
"now"), then the ones needing input (the red and yellow tab alerts), then
everything else by most recent activity, tab order breaking ties.

The ranking is pure (rankTileSessions in constants.js) and reuses the home
screens' classification (_mobileOverviewState) and stamps. It feeds the
toggle's fresh set, a count picked in the menu, Ctrl/Cmd+click and an open
split's fill. The active session is still always included and focused,
detached sessions are never tiled, and a page without the classifier falls
back to tab order. The vm harness now loads mobile-overview.js so the app
tests exercise the real classifier.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:39:51 +02:00
Codeman maintainer 55cd59a346 chore: changeset entry for the faster session close
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:02:48 +02:00
Codeman maintainer 209a539489 Merge master into the 1.41.0 release branch (faster session close, README GIFs) 2026-10-10 05:02:29 +02:00
Codeman maintainer 71e6cfa0a2 docs(tabs): drag stays on during a rail search (#580)
Owner decision: searching for a tab and dragging it into a group is a useful
flow, so the drag-off guard is reverted and the docs and changeset say drag
works during a search.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 05:01:34 +02:00
Codeman maintainer be9454a29b Revert "fix(tabs): no drag while a rail search is active (#580)"
This reverts commit 183efacc93.
2026-10-10 05:01:34 +02:00
Codeman maintainer 29c2701f83 Revert "fix(tabs): a found tab still drops onto the tile grid during a rail search (#580)"
This reverts commit f152d551d1.
2026-10-10 05:00:50 +02:00
Codeman maintainer f152d551d1 fix(tabs): a found tab still drops onto the tile grid during a rail search (#580)
183efacc turned drag off while the rail search narrows the list, as the
owner asked, by refusing the flat manual rail's dragstart. That also
refused a drag that never reorders anything: with the tile grid open, a
found tab dragged onto a tile or an empty cell set no draggedTabId, so
_acceptTabDrops() (tile-grid.js) saw nothing and the drop was lost. On
the PR head that drop replaced or swapped the tile. The owner's reason
covers the reorder only: a reorder drop is saved for every device,
relative to rows the search hides, while the tile grid is per-device and
lands beside nothing hidden.

dragstart is unconditional again. The rows refuse the reorder instead:
their dragover returns before preventDefault while _tabRailSearchActive()
(the browser shows no-drop and fires no drop there), and their drop
returns before touching sessionOrder, for anything above the row that
might let a drop through. `draggable` stays on, so a cleared search
reorders with the same rows. The grouped rail's pointer drag keeps its
refusal in _onTabLayoutPointerDown(): it has no drop target besides the
rail.

Test: during a search that leaves both rows showing, dragstart is not
cancelled and sets draggedTabId, dragover on another row is not
accepted, a drop there leaves sessionOrder alone and saves nothing, the
real _acceptTabDrops() binder accepts the drag on a tile and hands it
the id, and after clearing the same rows reorder. It fails on the
previous commit, without the dragover guard, and without the drop guard.
CLAUDE.md, the invariants, the Dashboard wiki row and the release
changeset now say reordering by drag is off and a tile drop still works.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:45:35 +02:00
Codeman maintainer 6844cccd18 fix(tabs): no connector from a parent row the search hid (#580)
A floating subagent or ultracode window whose parent session's row the
rail search hid drew its connector from the viewport's top-left corner.
The hidden row is display:none, and getBoundingClientRect() still
answers it with an all-zero DOMRect, which is truthy, so the
`if (!tabRect) continue` paths never skipped it and _tabAnchor() put the
line at (0, 0) ("M 0 0 C 350 0, 350 400, 700 400" in Chromium). The
sidebar filter already had the same defect; #580 brought it to the rail,
and the keystroke redraw from 5f373a93 now ran it on every keystroke that
hides the parent.

A new _paintedSessionTab() in app.js returns the parent's row only while
getClientRects() is non-empty, and every place a floating window
measures its parent tab goes through it:

- the shared `tab:<id>` rect cache, filled by the subagent connectors
  and by both ultracode connector layers (all three fill it, so guarding
  only the first would let the next one cache the zero rect itself);
- the ultracode window's spawn position, which now cascades;
- the subagent window's fly-from-tab spawn, the same defect, not in the
  report, now positioned as a window without a tab;
- the ultracode genie on minimize, which now tears down at once.

Lineage lines need nothing: they measure on a cache miss and computeTree
already drops zero-size rects.

Tests: in the gate, with the page laid out by hand (jsdom has no
layout), a subagent window, an ultracode run window and an ultracode
agent window from a hidden row draw no line and draw it again from the
row once the search is cleared, an ultracode window spawns from a
painted row and cascades from a hidden one, and the genie is skipped for
a hidden row. In the browser suite, real Chromium shows the hidden row's
rect is all zero and the connector is gone, then back after Clear. All
four fail on the previous commit. CLAUDE.md and the invariants'
Connectors bullet record the rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:37:49 +02:00
Codeman maintainer ac8e0bdcbb chore: changeset entry for #580
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:15:26 +02:00
Codeman maintainer 9d6c010819 docs: record the rail search in the architecture invariants (#580)
CLAUDE.md gained a rule for the rail's Search sessions box and linked it
to architecture-invariants#session-list-layout-header-strip-vs-left-sidebar,
which said nothing about it, and the "Collapse is per-device" bullet under
Owner tab layouts had an exception it did not record.

- Session list layout: one paragraph on the shared _applyTabListFilter()
  over the pure CodemanTabSearch (classes only, layout-scoped hide rules,
  rail matches the name and the sidebar name + folder, locale-independent
  lower-casing), the alert-row keep (owner decision), the data-total count
  restore, the tree walk and roving-stop fix-up, the projection opening
  every group, the connector redraw, the global Escape claim, no drag
  while searching, and the reset off the rail.
- Owner tab layouts: the collapse bullet notes that a search draws every
  group open and refuses toggles without writing the stored set.
- CLAUDE.md: the Escape claim and the connector redraw as one clause on
  the existing rail search rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:11:00 +02:00
Codeman maintainer 183efacc93 fix(tabs): no drag while a rail search is active (#580)
From the owner's review of #580 rather than the bot's report: "Drag
while searching: I'd turn it off. A drop is saved for every device
(session order or the tab layout), and where it lands relative to the
rows the search is hiding is something you only see after clearing it."
The PR head left drag on.

Both rail drags now refuse while the search narrows the list: the
grouped rail's pointer drag in _onTabLayoutPointerDown(), and the flat
manual rail's HTML5 drag in its dragstart listener. The flat rail is
refused in the listener, not by flipping `draggable`, because a
keystroke in the box does not re-render the rows, so a cleared search
drags again with the same rows. The sidebar filter box, the header strip
and the keyboard moves (Ctrl+Shift+{ }, the row menu) are unchanged.

Tests: a grouped-rail press during a search starts no drag and one after
clearing does; a flat-rail dragstart during a search is refused and one
after clearing goes through. CLAUDE.md and the Dashboard wiki row say
so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:10:05 +02:00
Codeman maintainer 4a94eb9ba7 fix(tabs): lower-case search text without the browser locale (#580)
CodemanTabSearch lower-cased the query and each row with
toLocaleLowerCase(), i.e. the browser's locale. Under a Turkish or Azeri
locale "API Review" lowers to "apı review", so a search for "api" missed
it, and the sidebar filter box, which used locale-independent
toLowerCase() before #580, changed matching with it. Both now use
toLowerCase(), like every other frontend search.

Test: a vm context of its own whose toLocaleLowerCase behaves like the
Turkish locale (the prototype is that context's alone) checks that "API"
still normalizes to "api" and that "API Review" matches. The test file's
overview also lists the pins the #580 fixes added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:08:32 +02:00
Codeman maintainer e589004926 fix(tabs): hide a case box the sidebar filter emptied (#580)
Since the sidebar filter and the rail search share _applyTabListFilter(),
the sidebar's filter box also marks a .tab-cluster it emptied in the
by-case tab layout and rewrites its count. But the only rule hiding
.tab-cluster.tab-filtered-out was the rail-scoped one, so in the sidebar
the emptied box stayed painted with its label and a count of 0 while its
rows were hidden.

The sidebar rule now hides the marked case box as well. It stays scoped
to html[data-session-list="sidebar"], so a leaked class still cannot hide
anything on the header strip. An alerted row still counts toward its box
(the owner's call on #580), so a box holding a row that needs the user
never hides.

Tests: a unit test renders the sidebar in the by-case layout and checks
the emptied box is marked with a 0 count, that an alerted row keeps its
box on screen and counted, and that clearing restores the totals; a
stylesheet check pins the scoped selector; the browser test measures the
shipped styles.css in Chromium: the marked box is not painted in the
sidebar layout, an unmarked one is, and the header layout hides nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:08:07 +02:00
Codeman maintainer 5f373a9324 fix(tabs): redraw connector lines when a search moves the rows (#580)
A keystroke in the rail search box that does not change the group
structure only toggles classes, so hidden rows collapsed and the rows
below them moved up while the lineage lines and the subagent/ultracode
connectors kept pointing at the old positions until the next render. The
sidebar filter box had the same gap for its connectors.

_applyTabListFilter() now notes whether anything it touched actually
appeared or disappeared (a row, a group or case box, the state headings
via tabs-filtering, the "No sessions match" line) and calls
updateConnectionLines() only then. That covers both boxes and the alert
re-render, costs nothing on the re-apply every render tail runs when
nothing changed (the incremental path's lineage gate keeps its meaning),
and coalesces with a render's own redraw.

Same flag, owner's optional nit from the review: the grouped tree's
posinset/roving-stop fix-up at the filter tail now runs only while a
search hides something or right after one changed what shows, since both
render paths already set them over an unfiltered tree.

Tests: unit tests for the rail keystroke path (flat and grouped rail, no
render taken), the empty note, the sidebar box, and no redraw when
nothing moved; the browser test checks in Chromium that a class-only
keystroke moves a row up and redraws exactly once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:06:50 +02:00
Codeman maintainer ac5f2aa3f4 fix(tabs): Escape in the rail search box clears only the search (#580)
The global key handler in setupEventListeners() sits on document in the
capture phase, so it ran before the box's inline onkeydown: the Escape that
cleared the search also ran closeAllPanels() (collapsing the Monitor and
Subagents panels), closeHelp() and closeSessionManager(). Calling
stopPropagation() from the inline handler came too late.

The global Escape branch now claims an Escape whose target is
#tabRailSearch while the box holds text, the same way it already claims
one for the group menu, the grouped-rail drag and the Tiles count menu,
and routes it to handleTabRailSearchKeydown(). An empty box still leaves
Escape to the global handler, and an Escape that cancels an IME
composition is left to the IME.

Tests: the unit test installs the real global listener and asserts that
Escape with text in the box closes nothing, that an empty box closes as
before, and that an Escape outside the box is not claimed. The browser
test installs setupEventListeners() for real in Chromium, so the
capture-before-inline ordering is the shipped one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:05:57 +02:00
Codeman maintainer 2881ef5fa5 Merge pull request #580: feat(tabs): search sessions by name on the vertical rail 2026-10-10 03:54:39 +02:00
Codeman maintainer 310a4c2489 chore: changeset entry for #581
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:41:38 +02:00
Codeman maintainer 29d8ca16b4 fix(mcp-sync): route tests never follow COPILOT_HOME, and docs name Copilot (#581)
The route test cleared only the registry CLIs' relocation vars, so with
COPILOT_HOME exported it wrote its fixture into that real Copilot config.
It now clears the sync-only tools' vars too, and Copilot's install probe goes
through the test's own installed set instead of the machine's PATH.

Docs: CLAUDE.md, the API reference and the Settings reference name COPILOT_HOME
and the sync-only table; a missing comma in docs/cli-registry.md; the
mcp-sync.ts overview and the Sync confirm mention Copilot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:41:38 +02:00
Codeman maintainer 92e54163e0 Merge pull request #581: feat(mcp-sync): sync GitHub Copilot CLI's MCP servers too
Conflict in docs/wiki/Settings-Reference.md resolved by keeping #565's Apply
wording and adding Copilot (and COPILOT_HOME, which the bot's review found
missing from this list).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:40:20 +02:00
Codeman maintainer 39a91a0c7f chore: changeset entry for #574
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:39:45 +02:00
Codeman maintainer cea199651a Merge pull request #574: feat(uploads): write prompt uploads to a hidden, self-ignoring .codeman-uploads/ folder
Conflicts resolved against the release branch: docs/api-reference.md keeps
both new sections (codeman agent CLI, then Prompt uploads);
test/test-ports-guard.test.ts takes the release side (#570 already removed
the legacy list); test/paste-image-dir-shared.test.ts keeps #570's ephemeral
port and this PR's bounded-path-probe mock.

Also at merge: the Docker sentence in the route comment and the API reference
is narrowed to owned cases, since an adopted container mounts nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:39:33 +02:00
Codeman maintainer 656ab98934 chore: changeset accuracy (notification time range, landing fixes)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:37:24 +02:00
Codeman maintainer e6382ecfc0 fix(i18n): zh-CN for the Animations settings section, and tab names are never translated (#571)
The new Animations section (rail item, heading, Entrance Theme and Tile
Animations rows with their options, the lab row) had no zh-CN entries.

Tab names were not in SKIP_SELECTOR (the list names .session-tab-name, which
the strip does not use), so a session called "Lab", "Soft" or "New session"
rendered translated. .tab-name is skipped now, like every other user-text
surface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:37:24 +02:00
Codeman maintainer 4d6d187883 Merge master into the 1.41.0 release branch (README and dashboard tour docs) 2026-10-10 03:34:58 +02:00
Aamer Akhter d3fc07ee28 feat(tabs): keep alerted tabs visible during a rail search
A session with a tab alert (red action or yellow idle, whatever tabAlerts
holds, the same set a collapsed group header surfaces) now stays visible
while the rail search or the sidebar filter is narrowing the list, even
when its name does not match. A prompt waiting on you should never be
hidden by a view filter.

The pure CodemanTabSearch.filter decides it: a row passed with keep: true
is never hidden. It counts toward its group, so the group stays on screen
and the header number is the rows left showing, but not toward
matchCount, so "No sessions match" still shows above a lone alerted row.
_applyTabListFilter() flags session rows from tabAlerts; web tabs carry
no alerts and are never kept.

No new wiring: updateTabAlertFromHooks() and _onSessionWorking() already
call renderSessionTabs(), and both render paths end in the shared filter.
2026-10-09 21:30:49 -04:00
Codeman maintainer b2de04c730 chore: the 1.41.0 release notes use the CRT clip with live header stats
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:15:49 +02:00
Codeman maintainer eada647d7b chore: changeset wording for #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:07:46 +02:00
Codeman maintainer 233d5f25c4 docs(echo): the package README describes the new scroll rule (#576)
The overlay no longer hides for any scrolled-up viewport, only when the
cursor row leaves the screen. The changeset gains the echo package's patch
bump and the #576 entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:07:34 +02:00
Codeman maintainer cf9022ac9a Merge pull request #576: fix(mobile): local echo keeps painting when the viewport is parked above the bottom 2026-10-10 03:07:18 +02:00
Codeman maintainer 87f1c9ccd4 chore: one 1.41.0 changeset, with the contributor changesets folded in
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 03:07:18 +02:00
Codeman maintainer d8c23210bb fix(notifications): zh-CN for the display-time settings, and review leftovers (#564)
- zh-CN entries for the two new rows (label and description).
- The toast test header still described the dropped drawer logging, and the
  noise test typed three members nothing uses any more.
- clickNotification's closing brace had been re-indented by accident.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:55:49 +02:00
Aamer Akhter 7c3877016b feat(tabs): search sessions by name on the vertical rail
A "Search sessions" box at the top of the vertical tab rail narrows the
list to the tabs whose name matches (case-insensitive substring; a web
tab matches by its title). It searches every group, collapsed ones
included: while a search runs the projection draws every group open and
the header will not toggle, and the stored per-device collapse state is
left alone. Groups with no match hide, an empty result says "No sessions
match", and the flat rail (no groups) filters the same way. Escape or
the clear button empties it; leaving the vertical orientation resets it.

It is a view filter only: rows get the same tab-filtered-out class the
sidebar filter box uses, through one shared _applyTabListFilter() over
the pure CodemanTabSearch matcher in constants.js. Grouping, order,
Alt+N badges and drag are untouched, nothing is persisted or sent to
the server. The sidebar keeps matching name plus working directory.

In the grouped tree, hidden rows and the headers of emptied groups leave
the roving walk and posinset/setsize, and the tab stop moves onto a
visible item. zh-CN strings added.
2026-10-09 20:55:32 -04:00
Codeman maintainer 7c4ad22f9c test(settings): the Apply test header names what Apply refreshes (#565)
Custom model endpoints were never gated on the saved flag; the PR's changeset
and JSDoc were narrowed in review, the test header was not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:55:01 +02:00
Codeman maintainer dd7a5b9275 docs(cli): CLAUDE.md names the 8-character id floor of codeman agent (#557)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:54:51 +02:00
Codeman maintainer 217c90b6a1 docs(tests): finish the ephemeral-port wording (#570)
- docs/browser-testing-guide.md still described the shrink-only legacy list
  and the mobile suite's fixed ports; both are gone.
- CLAUDE.md: name the one fixed port left (codex-predictive-echo's separate
  lab server on 3222), keep "Never 3000", and say the guard refuses a fixed
  port rather than that it checks boundPort is read.
- The tui-client comment described the old "port + 1" dead port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:54:39 +02:00
Codeman maintainer 93642fa616 test(viewer): the markdown anchor browser test binds an ephemeral port (#563)
The port guard refuses a WebServer built on a fixed port. It now uses port 0
and reads server.boundPort, like every other test server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:54:01 +02:00
Codeman maintainer 6db526dbad Merge pull request #562: fix(cli-install): install npm CLIs to ~/.local when the npm global prefix is not writable 2026-10-10 02:53:29 +02:00
Codeman maintainer e6deab98a6 Merge pull request #563: fix(viewer): make in-document links in rendered markdown scroll to their heading 2026-10-10 02:53:29 +02:00
Codeman maintainer 5e0cea6ec8 Merge pull request #565: feat(settings): add an Apply button that saves without closing 2026-10-10 02:53:28 +02:00
Codeman maintainer 2cc6ea1cfd Merge pull request #557: feat(cli): codeman agent — session-to-session verbs for every CLI mode (#445, phase 1) 2026-10-10 02:53:28 +02:00
Codeman maintainer c5ddb77adc Merge pull request #570: test: bind every test server to an ephemeral port (#440, 2/2) 2026-10-10 02:53:27 +02:00
Codeman maintainer d2d2ba3e4a Merge pull request #569: feat(tabs): a per-device switch to hide the CLI logos on tabs 2026-10-10 02:53:27 +02:00
Codeman maintainer 33ad314bea Merge pull request #578: fix(uploads): show the server's reason when a prompt upload fails 2026-10-10 02:53:26 +02:00
Codeman maintainer 3f4af2aa2e Merge pull request #577: fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history 2026-10-10 02:53:26 +02:00
Codeman maintainer cf11a253d3 Merge pull request #571: feat(tiles): Tile Animations setting, entrance styles for the tile grid 2026-10-10 02:53:25 +02:00
Codeman maintainer 17824f1847 Merge pull request #572: fix(git): pin git's locale for clone, so failures classify on non-English hosts (#568) 2026-10-10 02:53:25 +02:00
Codeman maintainer 53f61ca539 fix(tiles): the wheel scrolls Claude's fullscreen transcript in a tile, and Shift+wheel scrolls local history
A grid tile or the split's Pane B (TerminalTile) left the mouse wheel to
xterm for a session running Claude's fullscreen renderer (claude 2.1.187+
with mouse tracking on, cliMouseTracking). That renderer scrolls its own
transcript on SGR wheel reports, which the primary pane sends it, while the
tile's xterm holds only Codeman's replayed repaint frames (tmux keeps no
history for such a pane). So in a grid of fullscreen Claude sessions the
wheel either scrolled nothing or dragged stale frames, Claude's pinned input
box with them, up the tile, and Claude's transcript never moved. This was
tile-grid-plan follow-up 4.

The tile now forwards the wheel the way the primary pane does
(TerminalTile._maybeForwardWheelToCli): the primary pane's own gate,
_shouldForwardWheelToApp(ev, target), asked for the tile's terminal and
session; the cell from _clientPointToCell(x, y, tile.terminal); a scrolled-up
viewport snapped to the live screen first; and the reports flushed through
the tile's own 40 ms coalescer to the tile's session (the primary queue
flushes to the active session). The encoding moved into pure helpers in
terminal-ui.js, CodemanTerminalInput.wheelDeltaWholeLines and
sgrWheelReports, which the primary pane's _wheelScrollLines and
_sendSyntheticSgrWheel now call too, so the two panes send identical bytes.

Shift+wheel, the explicit local-scrollback gesture, was dead in every tile
off macOS: Chrome on Windows delivers it as a horizontal wheel (deltaX), and
xterm's own scroller turns a Shift+vertical wheel into a horizontal one. The
tile now scrolls it itself (_maybeScrollLocalOnShift: scrollLines() on the
dominant axis, sub-line travel carried over, a shell tile's history pull
still asked on the way up), as the primary pane's capture-phase handler does.

Unchanged: inline Claude, opencode and older Claude still take the
PageUp/PageDown route (#555), shells and other modes keep xterm's own plain
wheel, and a tracking xterm or the alternate buffer stays xterm's.

Tests: test/terminal-tile-scroll.test.ts covers forwarding (geometry, tick
cap, coalescing, viewport snap, the tile's session rather than the active
one, Shift/tracking/alternate exclusions, byte equality with the primary
pane) and Shift+wheel (Windows deltaX shape, sub-line carry, shell history
pull). test/terminal-tile-scroll.browser.test.ts adds a real-Chromium case
with trusted page.mouse.wheel() events.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:25:54 +02:00
JD 3be03c6896 fix(uploads): show the server's reason when a prompt upload fails
A failed upload only showed a count, so a remote session's refused upload read as a mystery instead of a rule the user could act on. The server already returns the reason in its error envelope and the per-file error carries it, so I surface the first one in the toast. One reason is enough when a whole batch fails for the same cause.
2026-10-09 20:13:58 -04:00
JD ba36cc36d4 fix(uploads): list upload dirs bounded and async, and keep uploads inside the data dir collected
The hourly sweep called lstatSync and realpathSync on every live session's working directory, so a linked case on a mount that stopped answering blocked the event loop 30 s after boot and then every hour; the old sweep was fully async. uploadDirs() now probes the workspace with probePathKind() first and skips an unknown path without touching it, then uses fs.promises for the lstat and realpath. The sweep keeps the probe's stall cap; cleanupSession(), acting on one path at the user's request, passes pastCap and removes the directories with fs.rm.

Refusing an upload dir that sits strictly inside the data dir protected nothing (it only ever holds uploads, and a link is already excluded by lstat) while the route kept writing there, so uploads under a workspace like the ~/.codeman/app that install.sh clones were never swept and never removed. Only the two cases that matter stay refused: the upload dir being the data dir, or containing it.

Also: the ignore file's take-back after a failed write no longer replaces the error that caused it with its own, and the shared-dir test's header no longer names the fixed port it stopped using.
2026-10-09 20:05:21 -04:00
JD 7aecbd29df fix(mobile): local echo keeps painting when the viewport is parked above the bottom
Anything typed on a phone vanished whenever the terminal viewport was not scrolled fully to the bottom. Codeman parks the viewport a few rows above the bottom on purpose (scrollToLastNonEmptyLine after a tab switch, or after the keyboard drops and the terminal regrows, so trailing blank rows stay off screen), and the overlay hid itself on a bare viewportY !== baseY test even though the prompt and the cursor were on screen. The text was buffered the whole time and Enter still sent it, which is what made the keyboard look dead.

The overlay now gates on whether the cursor row is inside the viewport (promptRowInViewport), in both the render path and the scroll handler, so a deep scroll into history still hides it. A buffer without cursorY keeps the old bottom-only rule.

Two of Codeman's custom prompt finders made the matching mistake of treating cursorY, which xterm reports relative to baseY, as a screen row; with the gate relaxed that would have painted the text on the wrong line. Both go through cursorViewportRow now, and the Claude finder looks for its glyph from the cursor's screen row up to the top of the live screen only, since a parked viewport shows scrolled-off history whose old composer rows used to be unreachable and would anchor the overlay on the wrong line.
2026-10-09 19:11:22 -04:00
JD f12b5ac88b feat(uploads): write prompt uploads to a hidden, self-ignoring .codeman-uploads/ folder
A pasted image landed in <workspace>/.claude-images/, a name that belongs to another tool, in a folder nothing ignored, so it showed up in git status of every case that ever received a paste. Uploads now go to a flat <workspace>/.codeman-uploads/ that carries a .gitignore of `*` (written once, never over a file already there): in the workspace because that is the only path identical for a local agent and a container, flat because a nested .codeman/ is the data dir itself when the workspace is the home directory.

The directory names live in paste-image-gc.ts alone. The old folder receives nothing but stays readable for one release: the hourly sweep and the delete cleanup go through uploadDirs(), the image watcher's ignore filter reads the names. uploadDirs() lists only real directories, none that is, contains or sits inside the data dir, and nothing for a remote session, since both consumers delete. The route refuses a remote (SSH) session before touching disk: the file would land on this host under the remote path, where the agent cannot read it.

test/paste-image-dir-shared.test.ts binds port 0 and leaves the port guard's legacy list.

Decided in #553.
2026-10-09 14:10:54 -04:00
Codeman maintainer 5206a044bb feat(settings): an Animations section in App Settings
Every animation setting now has its own App Settings section, right after
Appearance (owner: easier to find). It holds the Entrance Theme (the former
Entrance Animations row), Tile Animations, and an Open lab button that closes
settings and opens the per-surface lab (?animlab=1). Appearance keeps the skin,
identity and tab settings. New animation settings go in this section;
test/app-settings-structure.test.ts pins it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 18:22:14 +02:00
Codeman maintainer abc8598100 test(git): pin gitNonInteractiveEnv()'s locale so a revert fails in CI
CI runs in an English locale, where git already answers in English, so the
real-git clone tests stay green even if the LC_ALL/LANG pin from the previous
commit is dropped. A pure assertion on the env is the only check that fails
then. It also covers LANGUAGE, which gettext ignores once LC_ALL is C.

Refs #568

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 17:50:07 +02:00
RandalixandClaude Opus 5.5 6ba311f38d fix(git): pin git's locale for clone, so failures classify on any host
classifyGitFailure() matches git's English stderr, but cloneRepository()
spawned git with gitNonInteractiveEnv(), which left the host's locale in
place. Under de_DE.UTF-8 a missing ref came back as FAILED / 422 instead of
REF_NOT_FOUND / 400, and a missing repository as 422 instead of 404; the
clone dialog showed "git failed: Schwerwiegend: …". gitNonInteractiveEnv()
now sets LC_ALL=C and LANG=C, as git-workspace-status.ts already does.

Two tests depended on the locale on their own:
- git-status-routes: the spy runner called git without the production
  runner's LC_ALL=C, so "Kein Git-Repository" was not recognised.
- custom-model-run-menu-ui: the modal formats with toLocaleString(), the
  test hard-coded 16,384 and 40,000.

Full suite under de_DE.UTF-8: 5 failed before, 0 after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 17:48:28 +02:00
Codeman maintainer decd263b17 feat(tiles): Tile Animations setting, entrance styles for the tile grid
Tiles become the fifth entrance surface (data-tile-anim), switched on in App
Settings > Appearance > Tile Animations and OFF by default: the default is the
grid's own quick fade (`settle`), exactly as before.

A styled tile plays in two beats: its frame enters as it mounts (fly out of
its session tab, dealt from the Tiles button, CRT power-on, beam down from its
tab, cascade, pop, soft), and its screen then plays the terminal pane style of
the entrance theme when its first capture lands, through the same
html[data-term-anim] rules on .tile-body. Each style has its own exit when the
Tiles button closes the grid (back into the tabs, a CRT switch-off, ...).

Picking an Entrance Animations theme presets the tile style (new Launch theme:
tiles fly from the tabs); the theme readout ignores the tile row, so changing
it never shows "Custom". Frames move transform and opacity only (one fit and
one PTY resize per tile), a reload's restore always settles, and nothing moves
under reduced motion. The lab (?animlab=1) gets a Tile grid group, a cascade
order picker and in-place replay / close + reopen.

Also removes the terminal pane's `boot` entrance style (owner decision); a
saved `boot` falls back to off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 17:43:55 +02:00
Codeman maintainer 82aeeaec02 docs: CLI Logos on Tabs in the settings reference and the dashboard page
The Settings Reference gets the new row in the Appearance tab table, the
Dashboard's Session tabs section says what the logo is and where to turn
it off, and the CliEntry.shortBadge comment in docs/cli-registry.md no
longer implies every tab always shows a logo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:34:09 +02:00
Codeman maintainer ad57394618 feat(tabs): a per-device switch to hide the CLI logos on tabs
Since 1.40.0 every agent tab draws its CLI logo through the run-mode-dot
slot, and there was no way to turn that off. App Settings → Appearance →
Tabs now has "CLI Logos on Tabs" (showTabCliLogos), right after Tall Tabs.

- Modelled on tabTwoRows: a per-device display key in the server-settings
  merge and an optional boolean in the .strict() SettingsUpdateSchema,
  loaded and saved by the id appSettingsShowTabCliLogos. Default ON on
  every device; only an explicit false turns it off (tabCliLogosEnabled).
- CSS only, no tab re-render: applyTabOrientation() and the pre-paint
  script in index.html stamp html[data-tab-logos="on"|"off"] from the
  same stored blob (so a reload never flashes the logos), and one rule in
  styles.css hides .session-tab .tab-harness and .home-sessions-harness.
  The rows space their children with flex gap, so nothing is left behind.
- A live flip resizes every agent tab with no render behind it, so
  applyTabOrientation() then re-takes the strip's one-row wrap decision
  (updateTabOverflowMode) and re-anchors the lines drawn from tab rects;
  a header that changes height reaches the PTY through the terminal
  container's ResizeObserver, as any header change does.
- Covered: the header strip, vertical rail, sidebar, grouped rail, ledger
  and case clusters and phone chips (all render the same tab markup) and
  the desktop home rail. Untouched: tile and split headers, the Run menus,
  the welcome launchers. The phone overview rows, the command palette and
  the tab action menu draw no logo. The shell's SH pill and the status dot
  stay.
- zh-CN for the label and the description.

test/tab-cli-logos-setting.test.ts drives the real openAppSettings() and
saveAppSettings() in JSDOM (the saved PUT body must pass the schema), the
server-settings merge, the defaults on desktop and phone, the live stamp
and its re-measure without a re-render, the real pre-paint script (on,
off, the phone key, the catch fallback), the CSS rule's exact selectors,
and the translations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:34:09 +02:00
RandalixandClaude Opus 5.5 fda1897109 test(guard): no legacy list left; flag raw listeners on a fixed port (#440)
With the sweep done, LEGACY_FIXED_PORT_FILES and its staleness test go. A second rule flags a raw listen(<number or …PORT>) and a port: with a number or …PORT constant inside listen({ … }) or new WebSocketServer({ … }); a socket path and a lower-case variable pass. CLAUDE.md, AGENTS.md, CONTRIBUTING.md and the wiki's Contributing page lose the mobile exception, and CLAUDE.md names closedPort() instead of port + 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:29:02 +02:00
RandalixandClaude Opus 5.5 eceaecf076 test: raw test servers listen on port 0 (#440)
http/Fastify servers in daemon-control, deepseek-status-shim, session-input-wait and the three tui tests listen on 0 and read address().port. The two "nothing listens here" ports (a fixed 3243, and the server's port + 1) come from closedPort(): bind 0, read the port, close.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:29:01 +02:00
RandalixandClaude Opus 5.5 6f87186deb test(mobile): createTestServer() binds an ephemeral port (#440)
The helper cached servers by port, but every mobile file starts exactly one, so the cache was never shared: it is now a Set that stopAllTestServers() walks. PORTS leaves helpers/constants.ts, the nine tests set baseUrl after start(), and the README drops the port column and the pick-a-port step.

Committed without the pre-commit hook: accessibility, subagent-windows and visual-regression were not prettier-clean on master already; formatting them would bury the port lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:29:01 +02:00
RandalixandClaude Opus 5.5 aa4217b8b1 test: build every in-process WebServer on port 0 (#440)
The files on the guard's legacy list build new WebServer(0, …) and read server.boundPort after start(), as sse-tile-grid-filter does. Files with several servers read each server's own port; quick-start's local helper lost its port parameter so the literal 0 sits where the server is built. Header comments that named a port say "ephemeral". No assertion changed except where it embedded the port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:28:26 +02:00
Devvyn c75d62ce04 fix(settings): Apply cannot keep a later Save open, refreshes only after a saved PUT (review)
- Split the one-shot keep-open intent from the in-flight guard and consume it before the
  first await, so a Save clicked while an Apply is in flight closes the modal.
- Refresh the dependent groups only when the settings PUT returned ok (_apiPut answers null
  or a non-ok response instead of throwing), and also when only the webhook save failed.
- Find the 'Apply or Save' hint by a data marker, not its English text; add zh-CN strings
  for the new toast and title.
- Tests run the real saveAppSettings() (Apply then Save mid-flight, a failed PUT, a webhook-only
  failure, a plain Save).
- Narrow the changeset and JSDoc to MCP sync and CLI management; touch the tray comment, the
  architecture note and the wiki.
2026-10-09 19:46:09 +08:00
Devvyn c2e55fc210 fix(cli-install): address review: drop every npm_config_prefix spelling, probe async
- Delete every spelling of npm_config_prefix before setting NPM_CONFIG_PREFIX, in the Compose
  branch too: npm run exports the lowercase key and a sorting /bin/sh let it win. The
  operator guard stays on the uppercase key only.
- A prefix that does not exist yet is judged by its nearest existing ancestor.
- The probe is async (promisified execFile, fs.promises.access, SIGKILL on timeout), awaited
  before the spawn and only for commands that run npm.
- Tests: lowercase/mixed-case keys, and the decision logic against a fake npm on PATH
  (writable, read-only, not yet created, npm missing). Docs: one clause in cli-registry.md.
2026-10-09 19:42:06 +08:00
RandalixandClaude Opus 5.5 24e51a3b8e fix(cli): review — 8-char id floor, marker echo, wake answers on send
From the review of #557:

- An id shorter than 8 characters refuses with exit 4 before any request,
  on every verb. `rm 9` resolved to whichever session was alone with that
  first character (the user's own tab included) and deleted it. Same floor
  as the server's PARENT_SESSION_ID_MIN_PREFIX. `rm` no longer claims a
  lineage check: "Delete any session except this one".
- `wait --match` help and the README example say the marker must not appear
  verbatim in the prompt (its echo matches at once) and show the split form.
- `send` reads the route's wake-on-LAN answers: `buffered` gets its own
  line (exit 0), `dropped` exits 1 instead of printing "accepted".
- `--` for a prompt that starts with "-", in the `send` description and in
  the one-argument refusal.
- `stripAnsi` builds on the shared one (OSC sequences go too); the
  inputRefusal JSDoc sits above inputRefusal again.
- docs/wiki/Driving-Codeman-From-An-Agent.md gets a `codeman agent` section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:59:56 +02:00
RandalixandClaude Opus 5.5 0481db569d feat(cli): add codeman agent — session-to-session verbs for every CLI mode
The agent skill teaches the session verbs to claude only (Codeman seeds its
preamble for local claude sessions). An opencode, codex, pi or gemini agent has
the same environment — CODEMAN_MUX, CODEMAN_SESSION_ID and CODEMAN_API_URL are
exported into every pane — and nothing that teaches it the verbs, so
`codeman agent ls|spawn|send|wait|read|interrupt|rm` packages them as commands.

It is a client of the server, like `codeman tui`, and stays out of
`codeman session` (which drives the in-process SessionManager). No new route and
no second transport: everything goes through CODEMAN_API_URL, so auth,
ownership and the per-session waiter cap apply unchanged. Credentials and the
Basic header come from src/codeman-credentials.ts, in the same order attach and
the TUI use.

Invariants, each in test/cli-agent.test.ts:
- Refuses outside a Codeman session (CODEMAN_MUX=1 + CODEMAN_API_URL); never
  guesses a URL.
- `send` takes the prompt as ONE argument (an unquoted multi-line `$(…)` would
  otherwise be split by the shell and re-joined into one line), transmits
  printable text plus Enter only, and refuses multi-line input loudly instead of
  letting sendInput weld the lines. No resend loop of its own: the server's
  SubmitVerifier owns the swallowed-Enter case. ESC exists only as `interrupt`,
  which never appends Enter.
- `rm` fails closed: empty id, an unprovable self id, or a prefix match in
  either direction refuses.
- Nothing mode-shaped in the CLI: the readiness mark `spawn` waits for comes
  from the registry (new `capabilities.composerReadyMark`: claude's composer
  hint `shift+tab`, deepseek's `❯`), `read` relies on the route's own answer
  dispatch, and a `stop`/`blocked` the session cannot fire is the server's 400,
  passed through. A `/wait` timeout is a 200 with `timedOut`: exit 2 with a
  neutral line, not an error. A worker that dies during spawn's readiness wait
  is exit 3, like every other wait.
- `X-Codeman-Agent-Origin` rides only spawn's quick-start, the one request that
  may create a case directory; every other verb leaves it off. Server-side,
  test/routes/agent-case-marker-routes.test.ts pins that a POST /api/sessions on
  an existing workingDir is never labelled, header or not, and that quick-start
  labels only a directory it creates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:59:56 +02:00
RandalixandClaude Opus 5.5 6d862d5323 refactor(cli): one credential reader for every client of the API
`readCodemanEnv()` in cli.ts and `parseEnvFile()`/`readCodemanCredentials()` in
tui-client.ts were two copies of the same `.env` reader (the TUI's said so in a
comment), and `codeman agent` was about to need a third. They move into
`src/codeman-credentials.ts`; `codeman attach` and the TUI read from it, and the
TUI keeps re-exporting its names so nothing that imports them changes.

The lookup order is one pure function, `credentialsFrom(env, fileEnv)`: each
field from the environment, then the file, username defaulting to `admin` — the
order attach and the TUI already used. `readCodemanCredentials` takes the
environment as a parameter so a caller with its own (the agent CLI's guard) gets
the same answer. The parser now also tolerates an `export ` prefix, which the
agent skill's preamble already accepted in the same file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 12:59:48 +02:00
Devvyn 57d5c7b1c2 feat(mcp-sync): sync GitHub Copilot CLI's MCP servers too
Copilot CLI keeps its user MCP list in ~/.copilot/mcp-config.json (COPILOT_HOME moves it) but is
not a Codeman run mode, so it has no registry entry. Add the copilot-json dialect (mcpServers,
tools ["*"], type local/http/sse, checked against `copilot mcp add` 1.0.94) and declare Copilot as
a sync-only target in src/mcp-sync-targets.ts, listed after the registry CLIs. A server switched
off with `copilot mcp disable` is recorded in settings.json (disabledMcpServers), not on the
entry: sync reads that list so it is not copied, and reports the target unreadable if the file
is not valid JSON instead of guessing.
2026-10-09 18:42:00 +08:00
Devvyn 9c63c41283 feat(settings): add an Apply button that saves without closing
Switches such as MCP server sync only unlock their controls once saved, and
Save closed the modal, so the user had to reopen Settings to continue. Apply
runs the same save, keeps the modal open and refreshes the dependent groups
(MCP sync, custom model endpoints, CLI management) in place.
2026-10-09 18:41:59 +08:00
Devvyn 26487f0ec5 fix(cli-install): install npm CLIs to ~/.local when the npm global prefix is not writable
installEnv() only redirected NPM_CONFIG_PREFIX inside the Docker container
(CODEMAN_IN_CONTAINER=1). On a native install with a root-owned system node
(prefix /usr) `npm install -g @deepseek-ai/dsh` run as the server user died
with EACCES (exit 243), so DeepSeek, pi and other npm-based CLIs could not be
installed from Settings. Ask npm for its global prefix and, when the server
user cannot write it, redirect to $HOME/.local like the container path does.
An operator-set NPM_CONFIG_PREFIX and a writable prefix are left alone.
2026-10-09 18:41:58 +08:00
DevvynandClaude Sonnet 5.5 8da4a07a60 fix(viewer): make in-document links in rendered markdown scroll to their heading
marked emits no heading ids and, with <base href="/">, a bare #section href points at the dashboard root, so [Install](#installation) in the File Viewer did nothing. The shared click delegate now resolves fragment links against the rendered document: GitHub-style slugs in data-md-anchor (never ids, so a heading cannot capture an app element), case-insensitive and percent-decoded, repeats numbered -1/-2, # = top, explicit ids supported, an unmatched fragment ignored instead of navigating.

Tests: slug/assign/find unit tests (CI gate) and a real-browser test clicking links in a File Viewer document, which fails without the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
2026-10-09 17:01:44 +08:00
182 changed files with 10613 additions and 2098 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': patch
---
Switching to a fullscreen Claude tab is about ten times faster. Claude keeps that conversation itself and tmux holds no scrollback for it, so instead of downloading and replaying a 1 MB tail of old screen redraws on every switch, the browser loads just the current screen (a few KB), the same as a page load already did. Tabs whose pane does keep scrollback are unchanged.
-5
View File
@@ -1,5 +0,0 @@
---
'aicodeman': patch
---
The "Showing the most recent 1.0 MB of this session" notice no longer appears on every tab switch. It shows up only when you scroll to the top of a terminal, leaves when you scroll back down, and stays closed for that tab once you dismiss it. Sessions with nothing more to load (fullscreen Claude, whose history lives in Claude itself) never show it, and when there is more, it states the scrollback line count instead of an inflated byte figure. `GET /api/v1/sessions/:id/terminal` reports the new `paneHistoryLines` field.
@@ -1,5 +0,0 @@
---
'aicodeman': minor
---
Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s).
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.40.0",
"version": "1.41.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+1 -1
View File
@@ -57,7 +57,7 @@ Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot
The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000.
If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; mobile tests call `createTestServer()` and read `server.boundPort`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a fixed one. Never 3000.
Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions.
+3 -1
View File
@@ -107,7 +107,9 @@ todo.md
@fix_plan.md
readme-preview.mjs
# Uploaded images land here under each session working dir (runtime artifact)
# Prompt uploads land here under each session working dir (runtime artifact);
# .claude-images/ is where they landed before the move.
.codeman-uploads/
.claude-images/
# Local-LLM harness smoke-test config (real IPs/keys) — see the .example.json
+1 -1
View File
@@ -12,6 +12,6 @@ Quick pointers:
- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check`
- Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now
- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`); mobile tests use `createTestServer()` and read `server.boundPort`
- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`)
- Never commit secrets or local state from `~/.codeman/`
+46
View File
@@ -1,5 +1,51 @@
# aicodeman
## 1.41.0
### Minor Changes
- 87f1c9c: ### Thanks
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
![Codeman tile grid: six live agents powering on and off with the CRT tile animation](https://raw.githubusercontent.com/Ark0N/Codeman/08694b5862534b2b1224e9e272ad9858c787ebef/release-1.41/tiles-crt-stats-800.gif)
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
**Fixes.**
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
## 1.40.0
### Minor Changes
+13 -9
View File
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -736,7 +736,7 @@ For AI agents and automation that control Codeman without a browser: an agent th
Everything in this section also ships as a **Claude Code skill** in [`skills/codeman`](skills/codeman/SKILL.md). Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
<p align="center">
<a href="docs/images/codeman-skill-20261010.png"><img src="docs/images/codeman-skill-20261010.gif" alt="A real codeman skill run: one plain-English request to a lead session, three Claude Code workers opening as new tabs, and lineage lines from the lead to every worker" width="900"></a>
<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
@@ -944,6 +944,24 @@ codeman tui --list # numbered session list (plain tex
codeman tui 3 # attach to session 3 of that list
```
### `codeman agent` — session-to-session verbs in every CLI mode
The skill above is claude-shaped (Codeman seeds its preamble for claude sessions only). An `opencode`, `codex`, `pi` or `gemini` agent has the same environment (`CODEMAN_MUX=1`, `CODEMAN_SESSION_ID`, `CODEMAN_API_URL` are exported into every pane) but nothing that teaches it the verbs — so `codeman agent` packages them as commands. It is a thin client over the endpoints listed under [API](#api): no new route, no new transport, auth and ownership unchanged. One line in a case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
```bash
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000 # --wait = default signal set
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes (opencode, pi, …): ask for the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and wait on the joined form, which the prompt's echo never contains
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
Rules the commands enforce rather than document: they refuse outside a Codeman session and never guess a URL; `send` transmits printable text plus Enter only (a control byte such as `Ctrl+C` is `app_exit` in opencode — ESC exists solely as `interrupt`, which never appends Enter); `rm` refuses an empty id, an unprovable self id and a prefix match in either direction. Ids may be the 8-character prefixes `ls` prints (resolved through the list; an ambiguous prefix refuses, anything shorter than 8 characters refuses on every verb). A prompt that starts with `-` goes after `--` (`send "$SID" -- "- fix the bug"`). The echo of the prompt you sent is output too: a `--match` marker that appears verbatim in the prompt matches at once, before the worker has done anything, so the prompt asks for it in halves. A remote session whose host is asleep answers a fire-and-forget `send` with `buffered` (Codeman wakes the host and types the prompt once the pane is back) or `dropped` (over the wake buffer's cap, nothing will be typed: exit `1`). Exit codes: `0` delivered/matched/signal, `1` error, `2` timeout, `3` the worker exited or the wait ended without an answer (`delivered:false`, `ended:true`), `4` refused. `spawn` prints the id alone on stdout (prose goes to stderr), so `SID=$(…)` captures exactly the id. `--json` prints the envelope's `data` for every verb. `--until stop` on a mode without hook signals is the server's 400, passed through — the marker path (`--match`) is the answer there, exactly as for the skill.
### Hooks (events flowing _back_ to Codeman)
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
+2 -1
View File
@@ -22,6 +22,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
'test/tab-rail-search.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -45,7 +46,7 @@ export const BROWSER_TEST_GLOBS = [
'test/spreadsheet-preview.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
'test/run-mode-menu-scroll.browser.test.ts',
'test/fullscreen-tab-switch-capture.browser.test.ts',
'test/markdown-anchor-links.browser.test.ts',
];
/**
+25 -9
View File
@@ -457,13 +457,8 @@ client that opens many concurrent waits against one session will still hit the c
What a session's terminal shows, for a client to replay: `data.terminalBuffer`,
with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`,
`truncationReason`, `fullSize`, and `captureCols`/`captureRows` when the pane's
geometry was read. `paneHistoryLines` (present whenever the body is a pane capture)
is the number of scrollback rows tmux holds above the visible frame, which is the
most a `full=1` request can add. `truncated` describes the byte stream instead: for a
pane with `paneHistoryLines: 0` (a fullscreen CLI in the alternate screen) the bytes a
`tail` cut dropped are earlier repaints that no request returns. The capture runs
synchronous tmux calls on the server; the `Server-Timing` header reports `capture`,
`prepare` and `total`.
geometry was read. The capture runs synchronous tmux calls on the server; the
`Server-Timing` header reports `capture`, `prepare` and `total`.
| Query | Meaning |
|---|---|
@@ -471,6 +466,27 @@ synchronous tmux calls on the server; the `Server-Timing` header reports `captur
| `tail=<bytes>` | Keep the newest `<bytes>` of the result (`truncationReason: 'tail'` when it cut). |
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. `truncated` and `truncationReason` describe byte cuts only, not this bound. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
## The `codeman agent` CLI (client over these endpoints)
`codeman agent ls|spawn|send|wait|read|interrupt|rm` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents in modes that never receive the claude-only skill preamble. It adds no route: `spawn` is `POST /api/v1/quick-start` (+ `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry, where it declares one), `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`)
A `multipart/form-data` body with one `image` part. The file is written into the
session's workspace as `<workingDir>/.codeman-uploads/paste-<ms>-<hex>.<ext>`, and
`data` carries `path` and `filename` for the client to type the path into the
prompt. The folder is Codeman's own: hidden, created on first use with a
`.gitignore` containing `*` (written once, never over a file already there), and
cleaned up the way pasted images always were: `paste-*` files older than 7 days
go in an hourly sweep, and the folder goes when the last session of that
workspace is killed. Uploads made before this release sit in `.claude-images/`;
that folder receives nothing new, and is swept and removed the same way for one
release. A remote (SSH) session answers 400, since the file would land on the
Codeman host under a path the remote agent cannot read. A Docker session of an
owned case is fine, its workspace is bind-mounted at the same absolute path; an
adopted container (`owned: false`) mounts nothing, so its agent can open the file
only if the container itself exposes that host path.
## Session lineage (`parentSessionId`)
A create request may name the session that spawned it, which the web UI draws as a
@@ -817,10 +833,10 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
File diff suppressed because one or more lines are too long
+7 -5
View File
@@ -77,11 +77,13 @@ A test that starts a server binds an ephemeral port, never a fixed one:
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
excepted).
- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
convention in `test/mobile/README.md` for now.
any `WebServer` built under `test/` on a non-zero port.
- A raw `http`, `net`, Fastify or `ws` server: `listen({ port: 0 })`, then
`address().port`. The guard also fails a raw listen on a number or a `…PORT` constant.
- The mobile suite (`test/mobile/**`) gets its server from `createTestServer()`, which
binds an ephemeral port too; read it from `server.boundPort`.
- The one exception is `test/codex-predictive-echo.test.ts`, which starts a separate lab
server process on port 3222 that the guard cannot see.
- Never port 3000: that is the live instance.
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
+6 -4
View File
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
interface CliEntry {
id: CliId; // 'codex'
label: string; // 'Codex' — shown in menus
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
accent: string; // single hex colour
enabled: boolean;
stock: boolean; // set by the loader; a custom entry can never claim it
@@ -265,9 +265,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

+101 -34
View File
@@ -45,20 +45,28 @@ or settled a question the spec left open. The invariants as built are in
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
compare with the last English value, not the translated DOM.
- **The Tiles button opens the grid at once** (owner decision 8, with the count of
decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered
count of tiles (default 6, at most what the window fits). `tileGridOpenSet`
(constants.js) picks the grid this tab last had, else an open split's two sessions, else
the open sessions in tab order, the active one always included and focused, and
`tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it
(from tab order) to the count. A remembered grid comes back with its tiles first, in
their cells, then sessions in tab order, to the count in total: the count is a shape
change under the cell model's rule (`reformTileCells`: the tiles keep their row and
column when all fit, else they pack in reading order) and the added tiles fill the empty
cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A
remembered grid still wins over an open split: the split closes and its sessions are not
seeded first. A page-load restore brings back exactly the stored grid, whatever the
count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that
session among them and focused (owner answer: N, not N+1).
decision 10 and the layout memory of decision 11): a click (and `Ctrl+Shift+G`, the same
`toggleTileGrid`) brings back the grid this browser last had EXACTLY as the user left
it: which session sits in which cell, holes included, its tile count, divider sizes,
focus and a zoom the user chose (`restoreTileGridCells`, constants.js). It is never
filled to the remembered count and never trimmed to the window (a window too small
for it shows the focused tile alone until it fits, the arrangement kept). A session
that no longer exists frees its cell, which the ranking fills. Only with nothing
stored, or none of its sessions left, does `tileGridOpenSet` (constants.js) take an
open split's two sessions, else the open sessions as `rankTileSessions` orders them
(owner request: "prefer to load in tiles that are working and then the most recent,
so the oldest don't get opened"): WORKING first (the most recently started turn
first, keyed off `lastSubmitAt` only), then the ones NEEDING INPUT (the red and yellow
tab alerts), then the rest by most recent activity, tab order breaking ties; the
active one always included and focused, and `tileGridSetForCount` trims it (from the
end, the session to focus kept) or fills it (from the ranking) to the remembered count
(default 6, at most what the window fits). The states and stamps are the home
screens' own (`_mobileOverviewState`, `sessionActivityAnchor`). A remembered grid still
wins over an open split: the split closes and its sessions are not seeded first. A
page-load restore brings back the same grid as the toggle. Ctrl/Cmd+click on a tab
with the grid closed opens what the toggle would with that session among the tiles and
focused, never past the count (owner answer: N, not N+1): it joins the first empty cell
while the grid holds fewer than the count, else it takes the last tile's place.
- **A hover card on the Tiles button says it** (owner feedback: "give me the hover info
to right click over the tile button to adjust it"). It replaces the button's native title:
"Tiles · N" (the remembered count, live), what a click does (open or close the grid),
@@ -80,9 +88,10 @@ or settled a question the spec left open. The invariants as built are in
back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another
element close it (the single view a close starts focuses its terminal when its replay
lands; a menu left open behind that would send its keys there). A pick is remembered per
device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many
tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always
stays, the others leave from the end or join from tab order, filling empty cells first,
device in `codeman:tile-count` and opens that many tiles (a stored grid re-formed to
it, its tiles first in their cells); with the grid open it re-forms it
(`_reformTileGrid`): the focused tile always stays, the others leave from the end or
join from the ranking, filling empty cells first,
every joining tile mounted and laid out before any connects (one fit, one PTY resize
each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab,
"Open group as tiles", Run) still add up to the cap of 6.
@@ -97,7 +106,10 @@ or settled a question the spec left open. The invariants as built are in
`test/header-icon-hover.test.ts`.
- **The grid opens and closes with a short animation, on by default** (owner request:
"when clicking on the tile button first make this animation nicer"). It is the grid's
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
own `settle` style, the default of App Settings → Animations → Tile Animations, which
switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
`beam`, ...; docs/architecture-invariants.md#entrance-animations).
Opening, each tile
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
@@ -389,16 +401,34 @@ the harness/model request; see "As built")
### Persistence
Decided: per device, restored on reload. Stored in localStorage key
`codeman:tile-grid`:
Decided: per device (per browser), restored on reload when it was open, and by
the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the
server:
```json
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
"colFr": [1, 1, 1], "rowFr": [1, 1] }
{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…",
"zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] }
```
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
duplicate ids on load. Never restored in a solo window.
Session ids and the layout, never content. `ids` are the CELLS in reading order,
`null` for an empty one. `count` is how many tiles the user's own last change
left (open, add, remove by hand, a count picked): a session that goes away by
itself (deleted, popped out, its socket refused) does not lower it, so the next
time the grid opens the ranking fills that place, while a hole the user made
stays. It is written on every change (a move, a divider drag at pointer-up, a
tile added or removed, a count picked, a focus, a zoom) and kept, as
`open: false`, however the grid closes: the toggle, a non-tiled tab,
`leaveTiles` or a `#session=` link (which flips `open` only, so a gone id still
frees its cell), Home, the width gate, the last tile, "Open group as tiles",
closing or killing sessions. Nothing is written while a stored grid is being
put back, so a half-built grid never overwrites it.
A pure sanitizer drops unknown, deleted, detached and duplicate ids on load,
reports the cells their sessions freed (`freed`), and derives `count` for a
value written before it existed (the number of sessions the cells name); the
old packed `ids` (no nulls) read as cells with no hole, and anything that is not
a v1 object is ignored. The format stays `v: 1`, so an older build still reads
a newer value (it ignores `count`). Never read or written in a solo window.
The restore runs INSIDE `handleInit`, in place of its initial
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
@@ -409,6 +439,17 @@ the main terminal never loads on that page load. A later `handleInit` (SSE
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
against the live list without rebuilding tiles that are still alive.
A cell freed since the grid was stored is filled during that restore, from a
ranking that knows each session's status and stamps (the init payload) but not
yet its pending approvals: `seedApprovals` asks the server for them
asynchronously, and the restore has run by the time they land. So on a reload a
session waiting on a permission dialog or an unseen finished turn ranks with the
quiet ones for that one fill (working sessions still rank first). Accepted: a
fill held back for the approvals would open fewer tiles, which can be another
shape, and then reshape the grid and move the user's tiles a second after the
reload; so approvals that land later never re-form a restored grid. The Tiles
toggle, run once the page has loaded, ranks with them.
### Gating
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
@@ -815,7 +856,8 @@ Separate follow-up PRs worth doing (see "Follow-ups").
open, and an open menu owns the Escape (it closes alone and the keyboard goes
back to the Tiles button, like the tab-group menu).
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
- **No secrets in localStorage**: the stored grid holds ids only.
- **No secrets in localStorage**: the stored grid holds session ids and its
layout only, never content.
- **Memory**: everything a tile creates is released in `destroy()`.
## Delivery: two PRs
@@ -1008,13 +1050,17 @@ exits green. Use the browser runner for those files and read the file count.
viewer keeps a stale width and renders garbled output (#464).
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
on drain) for grids over slow links.
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
a "Load full history" action inside a tile. (Done since: a tile pages a
hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's
#555 route, and hand-reports a plain click while its session has
`cliMouseTracking` on, both through the primary pane's gates aimed at the
tile. The SGR wheel forwarding itself is still open: a fullscreen Claude
tile leaves the wheel to xterm.)
4. Tile parity extras: a "Load full history" action inside a tile. (Done
since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
the primary pane's #555 route, hand-reports a plain click while its session
has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
renderer as SGR wheel reports from its own cells
(`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
since xterm turns it into a horizontal no-op off macOS.)
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
6. Named grid presets, possibly per owner on the server.
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
@@ -1053,7 +1099,8 @@ exits green. Use the browser runner for those files and read the file count.
is on right-click of the button (its title says so, as do the wiki and the
Help modal). Superseded in part by decision 10: right-click is now the count
menu, and a remembered grid is filled to the count instead of opening
exactly as stored.
exactly as stored; decision 11 then restored "exactly as stored" and put a
ranking in place of the tab order.
9. **No + in the tile header.** Decided by the owner ("remove the + button from
these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New
session in this case" went with it. Tiles are added from the Tiles button
@@ -1066,12 +1113,32 @@ exits green. Use the browser runner for those files and read the file count.
right-click menu offers 2, 4 and 6, remembered per device; the session
picker is gone, and decision 8's "picker on right-click" is superseded. The
owner's answers on the details: the count wins over a remembered grid's
size (its tiles first, in their cells, holes filled first, then tab order);
size (its tiles first, in their cells, holes filled first, then tab order;
superseded by decision 11: a click brings the remembered grid back as it
was, and only a count picked in the menu re-forms it);
Ctrl/Cmd+click with the grid closed opens the count in total, that session
focused; shrinking keeps the focused tile; only the toggle animates the
close; a remembered count larger than the window stays checked but greyed
and a click opens what fits; the close keeps its dimmed still until the
single view has painted (at most 700 ms); paced connect is in.
11. **The grid keeps the layout the user arranged, and a fresh one ranks by
work.** Decided by the owner ("when I hit the tiles button, it should prefer
to load in tiles that are working and then the most recent working, so the
oldest dont get opened ... when I moved around and modified it, save it per
browser the layout, so when I turn tiles off and on, always keep what the
last setting was, if there was no setting before take the working ones, that
ones needs input and then the most recent ones in order"). The layout
(cells and holes, tile count, divider sizes, focus, a zoom the user chose)
is saved per browser on every change and comes back exactly from the toggle,
however the grid closed, and from a reload when the grid was open (a grid
closed before the reload stays remembered for the toggle; the page shows the
single view); it is never filled to the remembered count nor trimmed to the
window. A session gone since frees its cell for the
ranking; with none left, the grid opens from the ranking (`rankTileSessions`:
working, then needing input, then most recent), which also fills every place
the grid fills on its own (a count picked in the menu, a freed cell, an open
split's fill). Supersedes decision 10's "the count wins over a remembered
grid's size"; the count menu itself, its counts and its other answers stay.
## Code anchors
+2 -3
View File
@@ -63,9 +63,8 @@ provide what they need; that means "not runnable here", not a regression.
Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so
tests cannot touch real sessions. If you add a test that binds a port, bind port 0
(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`),
or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`,
via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now,
because that helper caches servers by port.
or use `app.inject()` when no socket is needed. Mobile tests call `createTestServer()` and
read `server.boundPort`. Never 3000.
## Finding your way around
+32 -1
View File
@@ -4,7 +4,8 @@ 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.**
Two routes. Start with the skill.
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.
## The agent skill
@@ -59,6 +60,36 @@ DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alph
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
completion signals.
## The `codeman agent` commands
The skill is Claude-shaped: Codeman seeds its preamble for Claude sessions only. An
`opencode`, `codex`, `pi` or `gemini` agent runs in the same environment but has nothing
that teaches it the API, so `codeman agent` packages the same verbs as shell commands. It is
a thin client over the endpoints in [the manual path](#the-manual-path), so auth and
ownership apply unchanged, and it refuses to act outside a Codeman session. One line in a
case's `AGENTS.md` is enough: *"other sessions: `codeman agent --help`"*.
```bash
codeman agent ls # sessions; * marks this one
SID=$(codeman agent spawn scratch-1 --mode claude) # quick-start + wait for the composer where the mode has a ready mark
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
codeman agent interrupt "$SID" # a bare ESC, conversation intact
codeman agent rm "$SID" # any session except this one
```
- **Ids** may be the 8-character form `ls` prints. Anything shorter refuses, and so does an
ambiguous prefix.
- **`send`** takes ONE quoted argument of printable text and presses Enter. A prompt that
starts with `-` goes after `--`: `codeman agent send "$SID" -- "- fix the bug"`.
- **Markers** follow [the split-marker trick](#the-split-marker-trick): the echo of your own
prompt is output too, so ask for the marker in halves and wait on the joined form.
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
worker exited, `4` refused. `--json` prints the response's `data`.
## The manual path
The same operations as raw HTTP, for a CI bot, a shell script, or an agent without skill
+12 -2
View File
@@ -90,7 +90,6 @@ every session or only the active tab.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
@@ -99,11 +98,22 @@ every session or only the active tab.
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
| Tall Tabs | Taller tab strip. |
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
| Overview Home Screen | The phone home screen. On by default. |
### Animations
All per device, all off by default, applied as you pick them.
| Setting | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
### Models
Claude model cards, the 1M context window switch, the thinking effort segment and the
@@ -142,7 +152,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
### Notifications
+6 -7
View File
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. Desktop and tablet only. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
@@ -73,6 +73,10 @@ precedence there.
One tab per session, in your order, and that order syncs across your devices.
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
headers and the Run menus keep theirs.
**Status is carried by the dot and the tab's own styling:**
| Look | Meaning |
@@ -210,14 +214,9 @@ TUIs render correctly.
Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
A fullscreen Claude tab keeps its history inside Claude, so switching back to it
reloads only the current screen, which keeps tab switches fast.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. That notice only
appears once you scroll to the top, leaves when you scroll back down, and stays away
for that tab once you close it. Sessions whose CLI keeps its own history (fullscreen
Claude) never show it, since there is nothing more to load. Automatic output
history; press **Load full history** to pull the rest explicitly. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
+36 -16
View File
@@ -16,23 +16,32 @@ It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift
## Opening a grid
- **Tiles button**: one click shows the tiles straight away, as many as you last chose
(six until you choose; fewer if the window is too small or you have fewer sessions open).
You get the grid you last had, its tiles where they were, topped up with your open
sessions in tab order; if there is none, an open split's two first; otherwise your open
sessions in tab order, with the session you are on focused. With the grid open, the same
button closes it.
- **Tiles button**: one click shows the tiles straight away. If you have used the grid in
this browser before, you get it back exactly as you left it: the same sessions in the
same places, an empty place where you left one, the same number of tiles, your column
widths and row heights, the tile you were in, and a zoomed tile still zoomed. A session
closed since frees its place, which is filled the way a new grid is filled (below).
Otherwise, or when none of those sessions is left, you get as many tiles as you last
chose (six until you choose; fewer if the window is too small or you have fewer sessions
open): an open split's two first; otherwise the sessions that are working (the most
recently started first), then the ones waiting on you (red and yellow tabs), then the
rest, the most recently used first, so the oldest are the ones left out. The session you
are on always comes along and is focused. With the grid open, the same button closes it.
- **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the
count it opens and what a click and a right-click do.
count you chose and what a click and a right-click do.
- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles:
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device
and is what the next click opens. With the grid open, picking a count re-forms it: the
tile you are in always stays, extra tiles leave from the end, new ones join from your tab
order. A count the window is too small for is greyed out, with the reason.
**2**, **4** or **6**, each drawn as its layout. Your choice is remembered in this
browser. Picking a count opens the grid with that many tiles (the grid you left, its
tiles in their places, new ones in the empty places first), and it is what a click opens
when there is no grid to bring back. With the grid open, picking a count re-forms it: the
tile you are in always stays, extra tiles leave from the end, new ones join working ones
first, then the ones waiting on you, then the most recent. A count the window is too
small for is greyed out, with the reason.
- **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does.
- **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With
the grid closed it opens what the Tiles button would show, with that session among them
(still the count you chose in total). On macOS use
the grid closed it opens what the Tiles button would show with that session added: in the
empty place while the grid has fewer tiles than the count you chose, else in place of the
last tile (never more than the count). On macOS use
`Cmd`: `Ctrl`+click there opens the tab's rename instead.
- **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps
running), or onto an empty slot to add it. Dragging a session that is already tiled onto
@@ -89,7 +98,8 @@ keeps the focus.
A moved tile takes the size of the place it lands in: column widths and row heights stay
where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is,
the empty slot included, is saved with the grid and comes back on reload.
the empty slot included, is saved with the grid and comes back when you turn the grid off
and on, and on a page reload while the grid is open.
Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a
new tile takes the first empty place. When the number of tiles changes the grid's shape (four
@@ -122,8 +132,18 @@ session finder) shows that session on its own, the normal single view. The grid
remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the
same. Narrowing the window below the desktop width also returns to the single view.
The grid is saved on this device and comes back when you reload the page, with its focus,
zoom and column widths. A session that was closed in the meantime is simply left out.
The grid is saved in this browser every time you change it (moving, resizing, adding or
removing a tile, changing the count, focusing or zooming a tile), and never sent to the
server. However you leave it (the Tiles button, another tab, Home, a link, closing its last
tile or session), the Tiles button brings it back as it was. A page reload brings it back
when the grid was open; after you left it, a reload shows the single view and the Tiles
button still brings the grid back. A session that was closed or popped out into its own
window in the meantime frees its place for another one, picked the way a new grid picks
them; the place stays empty only when no other session is left. Right after a page reload
the page does not know yet which sessions are waiting for your answer, so that pick goes by
which sessions are working and which you used last. If the window has become too small for
all the tiles, the tile you were in fills the grid until the window is wide enough again,
and the rest of the layout is kept.
Split shows the same logo, name and model above both of its panes.
+1 -1
View File
@@ -14,7 +14,7 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.40.0",
"version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.40.0",
"version": "1.41.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -13389,7 +13389,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.4.0",
"version": "0.4.1",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "aicodeman",
"version": "1.40.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"version": "1.41.0",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -42,7 +42,7 @@
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json",
"knip": "npx --yes knip@latest --config config/knip.json",
"release": "changeset publish"
"release": "node scripts/npm-release.mjs"
},
"prettier": {
"singleQuote": true,
@@ -171,7 +171,7 @@
"bugs": {
"url": "https://github.com/Ark0N/Codeman/issues"
},
"homepage": "https://github.com/Ark0N/Codeman#readme",
"homepage": "https://getcodeman.com",
"files": [
"dist",
"scripts/postinstall.js",
+46
View File
@@ -1,5 +1,51 @@
# xterm-zerolag-input
## 0.4.1
### Patch Changes
- 87f1c9c: ### Thanks
- @Randalix for `codeman agent` (#557), the session verbs (`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) for agents in every CLI mode, and for moving every test server onto an ephemeral port (#570), which finishes #440. Thanks also for reporting and fixing git's clone errors on non-English hosts (#568, shipped as #572 with your commit).
- @opticon454 for five PRs: MCP server sync for GitHub Copilot CLI (#581), an Apply button that saves Settings without closing them (#565), configurable toast and browser-notification display times (#564), in-document links in rendered markdown that scroll to their heading (#563), and npm-based CLI installs that work when the system npm prefix is root-owned (#562).
- @JDProfresh for three PRs: pasted and uploaded files moving into a hidden, self-ignoring `.codeman-uploads/` folder (#574, after your #553 proposal), local echo that keeps painting on phones when the view sits above the bottom (#576), and upload failures that say why they failed (#578).
- @aakhter for session search on the vertical tab rail (#580), built on the sidebar's existing filter instead of a second one, and for keeping alerted tabs visible during a search.
![Codeman tile grid: six live agents powering on and off with the CRT tile animation](https://raw.githubusercontent.com/Ark0N/Codeman/08694b5862534b2b1224e9e272ad9858c787ebef/release-1.41/tiles-crt-stats-800.gif)
**Tile Animations (#571).** The tile grid from 1.40.0 can now open with a show. App Settings has a new **Animations** section (right after Appearance) that holds every animation setting: the Entrance Theme (moved out of Appearance), the new **Tile Animations** row, and a button that opens the animation lab. Tile styles: **CRT** (each tile switches on as a hot line in a diagonal wave, and switches off to a line and a dot), **Fly from tab** (each tile grows out of its session tab and flies back into it), **Deal** (dealt out of the Tiles button like cards), **Beam down**, **Cascade**, **Pop**, **Soft** and **None**. A styled tile plays in two beats: the frame enters in the tile style, then its screen powers on in the terminal style of your Entrance Theme. Picking a theme presets a matching tile style, and a new **Launch** theme flies the tiles out of their tabs. Off by default (the grid keeps its quick fade), per device, nothing moves under reduced motion, and the animations never cost an extra PTY resize. The terminal pane's Boot entrance style is gone; a saved Boot falls back to off.
**Tiles open the sessions you care about, and keep your layout.** With no grid arranged yet, the Tiles button (or `Ctrl+Shift+G`) now fills the grid with the sessions that are working first (most recently started first), then the ones waiting on you (a permission prompt or a finished turn you have not seen), then the most recently used, instead of the tabs in strip order, which opened the oldest ones. The session you are on is still always included. Once you arrange a grid (which session sits where, empty cells, the tile count, divider sizes, the focused and zoomed tile), that layout is remembered in this browser and the Tiles button brings it back exactly, however the grid was closed, and so does a reload while it was open. A session that has gone since frees its place, which the same ranking fills. A grid larger than the window opens in full with the focused tile zoomed, instead of being trimmed (and losing the rest of your layout).
**The wheel scrolls Claude inside a tile (#577).** In a tile or the split's second pane, a Claude session on its fullscreen renderer now scrolls its own conversation with the mouse wheel, exactly like the main terminal: the wheel goes to Claude as mouse reports, aimed at that tile's session and computed from the tile's own screen. Before, the wheel did nothing or scrolled the stale frames left over from loading the tile. Shift+wheel (scroll local history) works in every tile on Windows and Linux too; it was dead there.
**`codeman agent`: session verbs for every CLI (#557).** Agents in any mode (Codex, OpenCode, Gemini, Pi and the rest, not just Claude) can now drive other Codeman sessions from the command line: `codeman agent ls | spawn | send | wait | read | interrupt | rm`. It is a thin client over the existing session API: every call names the session that made it, `wait` blocks on a signal (`--until stop,exit`) or a literal output marker (`--match`), and exit codes say what happened (`0` ok, `1` error, `2` timeout, `3` exited, `4` refused). Ids shorter than 8 characters are refused, so a stray `rm 9` can never pick a session at random, and `rm` never deletes the session it runs in. See the README section "`codeman agent`" and the wiki page Driving Codeman From An Agent. This is phase 1 of #445.
**Settings: Apply (#565).** Next to Save, an Apply button saves the same way but keeps Settings open. Switching on MCP server sync makes its Preview and Sync usable straight away, and CLI management's add, enable and disable work without closing and reopening Settings.
**MCP server sync reaches GitHub Copilot CLI (#581).** With MCP server sync on (App Settings, off by default), Copilot CLI's `~/.copilot/mcp-config.json` now takes part like the agent CLIs' own files: its servers are copied to the others and theirs to it, additively, with the previous file kept as `.codeman-bak`. Copilot joins only when it is installed or already has that file, a server switched off in Copilot is never copied, and `COPILOT_HOME` is followed. Copilot is a sync target only, not a new run mode.
**Notifications stay up longer if you want (#564).** Settings → Notifications has a Toast display time and a Browser notification display time (1 second to 5 minutes, per device; the defaults stay 3 s and 8 s).
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, you can still drag a found tab into a group, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
**Closing a tab is instant.** Closing a session used to take half a second or more before the tab went away. The tab, tile or split pane now goes (and the next session is selected) the moment you click, while the server shuts the session down in the background, and the server side is faster too (about 450 ms down to 200-260 ms for a Claude session): it no longer sleeps fixed intervals, no longer freezes for about 70 ms per close on a synchronous tmux call, and scans for a session's subagents once instead of once per subagent. If the server refuses the close, the tab comes back where it was with the error. This also fixes a bug where a failed close still said "Session closed" while the session kept running.
**Fixes.**
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
- **Links within a markdown file (#563).** A link to another heading of the same document (`[Install](#installation)`) in the File Viewer or Response Viewer scrolls to that heading instead of doing nothing. Headings get GitHub-style slugs, repeated titles are numbered, and non-ASCII headings work.
- **npm CLI installs on a root-owned prefix (#562).** Installing an npm-based CLI from Settings (DeepSeek's `dsh`, pi, ...) no longer fails with EACCES when the system node keeps its global prefix under `/usr`: the install goes to `~/.local`, where Codeman already looks for CLIs. A prefix you set yourself, or one you can write to, is left alone, including when Codeman runs under `npm run`.
- **Uploads go to a hidden `.codeman-uploads/` folder (#574).** Images you paste or upload into a prompt are saved in `<workspace>/.codeman-uploads/` instead of `.claude-images/`. The folder ignores itself in git (it carries a `.gitignore` of `*`), stays hidden in the Files panel, and is cleaned up as before: files older than 7 days in an hourly sweep, and the folder when the last session in that workspace closes. The old `.claude-images/` folder gets nothing new and is still swept and removed during 1.41.x. An upload to a remote (SSH) session is now refused with a clear message, since the file would land on the Codeman host where the remote agent cannot read it.
- **Typing on a phone after a tab switch (#576, fixes #575).** With local echo on (the default on touch devices), text typed while the terminal sat above the bottom was buffered but never painted, so the keyboard looked dead. The view sits there after every tab switch and after the keyboard closes. The overlay now paints whenever the prompt row is on screen, and hides only when you scroll the prompt out of view.
- **Upload failures say why (#578).** When a prompt image upload fails, the toast shows the server's reason (for example a rate limit) instead of only "1 failed".
- **The npm page shows the English README.** npmjs.com had been rendering the Chinese README, because npm picks the package's readme from an unsorted file match at publish time. The publish now moves `README.zh-CN.md` aside while it runs (the file and every link to it stay as they are), and the package description and homepage say what Codeman is and point at getcodeman.com.
- **New cases ask for clickable file paths.** The CLAUDE.md generated into a new case asks the agent to report every file it created as a full absolute path, which Codeman turns into a link that opens the File Viewer, and mentions the codeman skill for starting and managing worker sessions.
**For contributors (#570).** Every in-process test server binds an ephemeral port, the mobile suite included, and the port guard now also refuses raw listeners on a fixed port, so two test runs on one machine never collide.
**Fixes applied while landing.** zh-CN translations for the two new notification display-time settings and for the new Animations section. A session whose name matches an interface word ("Lab", "New session") is no longer translated in the tab strip when the interface is in Chinese. Plus test and doc cleanups left over from review.
## 0.4.0
### Minor Changes
+1 -1
View File
@@ -537,7 +537,7 @@ While flushed text exists the prompt column is locked, so a full-screen redraw c
### Scroll awareness
The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
The overlay hides while the cursor row is scrolled out of the viewport and re-renders, debounced, when it scrolls back into view. A viewport parked a few rows above the bottom keeps painting as long as the cursor row is on screen. A buffer that reports no `cursorY` keeps the bottom-only rule.
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
"version": "0.4.0",
"version": "0.4.1",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
@@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
return '';
}
}
/**
* Whether the row the overlay draws on (the cursor row) is inside the viewport. A bare
* `viewportY === baseY` test is wrong for a host that parks the viewport a few rows above
* the bottom with the prompt still on screen; a buffer without `cursorY` keeps that rule.
*/
export function promptRowInViewport(terminal: XtermTerminal): boolean {
try {
const buf = terminal.buffer.active;
if (buf.viewportY === buf.baseY) return true;
if (typeof buf.cursorY !== 'number') return false;
const cursorRow = buf.baseY + buf.cursorY;
return cursorRow >= buf.viewportY && cursorRow < buf.viewportY + terminal.rows;
} catch {
return false;
}
}
@@ -8,7 +8,7 @@ import type {
FontStyle,
} from './types.js';
import { getCellDimensions } from './cell-dimensions.js';
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
import { findPrompt, readTextAfterPrompt, promptRowInViewport } from './prompt-finder.js';
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
@@ -122,11 +122,10 @@ export class ZerolagInputAddon implements XtermAddon {
// Cache font properties
this._cacheFont();
// Scroll detection: hide overlay when scrolled away from bottom
// Scroll detection: hide the overlay while the cursor row is scrolled out of view
this._scrollHandler = () => {
try {
const buf = this._terminal!.buffer.active;
if (buf.viewportY !== buf.baseY) {
if (!promptRowInViewport(this._terminal!)) {
this._overlay!.style.display = 'none';
if (this._scrollTimer) {
clearTimeout(this._scrollTimer);
@@ -565,8 +564,8 @@ export class ZerolagInputAddon implements XtermAddon {
try {
const buf = this._terminal.buffer.active;
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
if (buf.viewportY !== buf.baseY) {
// Hide the overlay while the cursor row is scrolled out of view
if (!promptRowInViewport(this._terminal)) {
this._overlay.style.display = 'none';
return;
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import { promptRowInViewport, findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import type { XtermTerminal, PromptFinder } from '../src/types.js';
function term(lines: string[]) {
@@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => {
cleanup();
});
});
describe('promptRowInViewport', () => {
const term = (viewportY: number, baseY: number, cursorY: number | undefined, rows = 24) =>
({ rows, buffer: { active: { viewportY, baseY, cursorY, getLine: () => undefined } } }) as never;
it('is true at the bottom regardless of the cursor', () => {
expect(promptRowInViewport(term(10, 10, undefined))).toBe(true);
});
it('is true for a viewport parked above the bottom while the cursor row is on screen', () => {
// scrollToLastNonEmptyLine() parks rows - 2 above the last non-empty row
expect(promptRowInViewport(term(0, 16, 5))).toBe(true);
// cursor exactly on the last visible row
expect(promptRowInViewport(term(0, 23, 0))).toBe(true);
});
it('is false once the cursor row is scrolled out of the viewport', () => {
expect(promptRowInViewport(term(0, 24, 0))).toBe(false); // one past the last row
expect(promptRowInViewport(term(0, 200, 3))).toBe(false); // deep in history
});
it('keeps the bottom-only rule when the buffer has no cursorY', () => {
expect(promptRowInViewport(term(0, 1, undefined))).toBe(false);
});
});
@@ -725,3 +725,34 @@ describe('ZerolagInputAddon', () => {
});
});
});
describe('viewport scrolled away from the bottom', () => {
function parked(viewportY: number, baseY: number, cursorY: number, rows = 24) {
const mock = createMockTerminal({ buffer: { lines: ['$ '], viewportY, baseY, cursorY }, rows });
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
return { addon, mock, overlay };
}
it('still paints with the viewport parked above the bottom while the cursor row is on screen', () => {
// The host parks the viewport to keep trailing blank rows out of view; the
// prompt and cursor are still visible, so the user's text must be too.
const { addon, overlay } = parked(0, 1, 0);
addon.appendText('abc');
expect(addon.pendingText).toBe('abc');
expect(overlay.style.display).not.toBe('none');
expect(overlay.textContent).toContain('abc');
});
it('hides once the cursor row is scrolled out of the viewport, even over a stale prompt glyph', () => {
const { addon, overlay } = parked(0, 30, 0);
addon.appendText('abc');
expect(addon.pendingText).toBe('abc');
expect(overlay.style.display).toBe('none');
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
"version": "1.40.0",
"version": "1.41.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+6
View File
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Workers in **every other mode** never receive this preamble, but they have the same
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
URL) enforced in code.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside.
*
* npmjs.com renders the package's top-level `readme`, and npm picks it at publish time:
* @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and
* keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the
* Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months.
* `files` cannot help: npm-packlist always includes every root `README.*`.
*
* Renaming the file would break every link to it, so for the length of the publish only it
* moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards,
* whatever the publish did. The move happens HERE, inside the publish command, never as a
* step before `changesets/action` in release.yml: that action also runs the version path and
* commits the working tree into its version PR, which would commit the deletion.
*
* A previous run killed between the move and the restore leaves the aside copy behind; the
* next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md.
*
* node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`)
*/
import { existsSync, renameSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const HIDDEN_README = 'README.zh-CN.md';
export const ASIDE_NAME = '.README.zh-CN.md.release-aside';
/**
* Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also
* when `publish` fails or throws. Returns the exit code `publish` returned.
*
* @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts
* @returns {number}
*/
export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) {
const original = join(root, HIDDEN_README);
const aside = join(root, ASIDE_NAME);
if (existsSync(aside) && !existsSync(original)) {
renameSync(aside, original);
log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`);
}
const moved = existsSync(original);
if (moved) {
renameSync(original, aside);
log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`);
}
try {
return publish();
} finally {
if (moved) {
renameSync(aside, original);
log(`npm-release: ${HIDDEN_README} restored`);
}
}
}
function runChangesetPublish() {
const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' });
if (result.error) {
console.error(`npm-release: could not run changeset publish: ${result.error.message}`);
return 1;
}
return result.status ?? 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish });
}
+6
View File
@@ -29,6 +29,12 @@ worked multi-worker flows in [reference/recipes.md](reference/recipes.md), endpo
tables and a symptom gallery in [reference/endpoints.md](reference/endpoints.md), and
direct messaging to claude workers in [reference/messaging.md](reference/messaging.md).
Workers in **every other mode** never receive this preamble, but they have the same
environment: tell them *"other sessions: `codeman agent --help`"* — the bundled CLI
(`ls`, `spawn`, `send`, `wait`, `read`, `interrupt`, `rm`) is the same verbs over the
same endpoints, with the guards below (no control bytes, no self-delete, no guessed
URL) enforced in code.
## 0. Guard and bootstrap
If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
+980
View File
@@ -0,0 +1,980 @@
/**
* @fileoverview `codeman agent …` — session-to-session verbs for the agent running
* inside a Codeman session, in every CLI mode.
*
* A thin HTTP client over endpoints that already exist (`quick-start`, `input`,
* `wait`, `wait-output`, `last-response`, `terminal`, `DELETE sessions/:id`). It
* invents no route and no transport: everything goes through `CODEMAN_API_URL`, so
* auth, ownership and the per-session waiter cap apply unchanged. The behaviour is
* the packaged agent skill's (`skills/codeman`), ported from shell prose into code
* with tests, so a `codex`/`opencode`/`pi` agent — which never gets the claude-only
* preamble — has the same verbs from one line in its AGENTS.md.
*
* Invariants (each asserted in `test/cli-agent.test.ts`):
* 1. Refuses outside a Codeman session (`CODEMAN_MUX=1` + `CODEMAN_API_URL`); it
* never guesses a URL — a server you are not part of is not yours to drive.
* 2. `send` transmits printable text plus `\r` only. ESC exists solely as
* `interrupt`, which never appends `\r`. A stray control byte is a dead session
* in the fullscreen TUIs (opencode's `Ctrl+C` is `app_exit`).
* 3. `rm` fails closed: empty id, a short self id, or a prefix match in EITHER
* direction refuses. Ids appear in full and 8-char form, so equality alone
* misses a real combination — and the miss deletes the caller.
* 4. An id shorter than 8 characters refuses (exit 4) before any request, on every
* verb. `9` would resolve to whichever session is alone with that first
* character, the user's own interactive tab included.
*
* Commands live here as functions returning an exit code, not calling
* `process.exit`, so the whole surface is unit-testable against a fake server.
*
* @module cli-agent
*/
import http from 'node:http';
import https from 'node:https';
import type { Command } from 'commander';
import {
basicAuthHeader,
credentialsFrom,
readCodemanEnvFile,
type CodemanCredentials,
} from './codeman-credentials.js';
import { getCli } from './config/cli-registry/registry.js';
import { GLYPH, palette, table } from './cli-style.js';
import { getErrorMessage } from './types.js';
import { stripAnsi as stripAnsiSequences } from './utils/regex-patterns.js';
// ─────────────────────────────────────────────────────────────────────────────
// Context and guard
// ─────────────────────────────────────────────────────────────────────────────
export interface AgentContext {
/** Base URL of the Codeman server, from `CODEMAN_API_URL`. */
apiUrl: string;
/** This session's id, from `CODEMAN_SESSION_ID`. */
selfId: string;
/** Basic-auth credentials, when the server has a password. */
auth?: CodemanCredentials;
}
/** Thrown when the process is not inside a Codeman-managed session. */
export class AgentGuardError extends Error {}
/** Exit codes shared by every verb; a shell agent can branch on them. */
export const EXIT = {
ok: 0,
error: 1,
timeout: 2,
dead: 3,
refused: 4,
} as const;
/**
* Resolve the context from the environment, or throw `AgentGuardError`.
*
* Credentials in the order every client of the API uses (`credentialsFrom`, shared
* with `codeman attach` and the TUI): each field from the environment (a session
* inherits the server's), then the data dir's `.env`. No password means the server
* is open (single-user) — or it is not, and the 401 says so.
*/
export function resolveAgentContext(
env: NodeJS.ProcessEnv = process.env,
envFile: () => Record<string, string> = readCodemanEnvFile
): AgentContext {
if (env.CODEMAN_MUX !== '1') {
throw new AgentGuardError('Not inside a Codeman-managed session (CODEMAN_MUX is not 1); refusing to act.');
}
const apiUrl = env.CODEMAN_API_URL?.trim();
if (!apiUrl) {
throw new AgentGuardError('CODEMAN_API_URL is not set; refusing to guess a server.');
}
const selfId = env.CODEMAN_SESSION_ID?.trim();
if (!selfId) {
throw new AgentGuardError('CODEMAN_SESSION_ID is not set; cannot tell which session is me.');
}
const credentials = credentialsFrom(env, envFile());
return { apiUrl, selfId, auth: credentials.password ? credentials : undefined };
}
// ─────────────────────────────────────────────────────────────────────────────
// Pure helpers (the invariants)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Is `id` this session? Prefix in BOTH directions, because ids appear in full and
* in 8-char form (mux names, UI surfaces, Docker's truncated `$SELF`). A self id
* shorter than 8 characters cannot prove anything and is treated as "maybe me".
*/
export function isSelfSession(selfId: string, id: string): boolean {
if (!id || selfId.length < 8) return true;
return id.startsWith(selfId) || selfId.startsWith(id);
}
/** Why `rm` refuses, or `undefined` when the delete may go ahead. */
export function deleteRefusal(selfId: string, id: string): string | undefined {
if (!id) return 'refusing: empty session id';
if (selfId.length < 8) return 'refusing: own session id unset or too short to prove this is not me';
if (isSelfSession(selfId, id)) return `refusing: ${id} is me`;
return undefined;
}
/**
* The prompt `send` was given, which must be ONE argument. Joining several with spaces
* would turn an unquoted `$(cat notes.txt)`, which the shell splits on every newline,
* back into a single line, so the multi-line refusal below would never see it.
*/
export function sendPromptFromArgs(words: readonly string[]): { text: string } | { error: string } {
if (words.length === 1) return { text: words[0] };
return {
error: `refusing: the prompt must be ONE argument, got ${words.length} — quote it (\`send <id> "…"\`; a prompt that starts with "-" goes after --: \`send <id> -- "- fix the bug"\`)`,
};
}
/**
* Why `send` refuses this text, or `undefined` when it is printable. The composer
* takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and
* DEL) reaches the pane as a keypress. None of that is a prompt.
*/
export function inputRefusal(text: string): string | undefined {
if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)';
// The composer is one line: the server strips newlines, which silently joins the
// lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle).
if (/[\n\r\t]/.test(text)) {
return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path';
}
// C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal).
// eslint-disable-next-line no-control-regex
const control = text.match(/[\x00-\x1f\x7f-\x9f]/);
if (control) {
const code = control[0].charCodeAt(0).toString(16).padStart(2, '0');
return `refusing: input contains control byte 0x${code}; send transmits printable text only (ESC is \`interrupt\`)`;
}
return undefined;
}
/**
* Body for `POST /sessions/:id/input` on the send path: text plus `\r` unless the
* caller asked to type without submitting. Never anything else.
*/
export function buildSendBody(
text: string,
options: { enter: boolean; clientId: string; seq: number; wait?: string | true; waitTimeout?: number }
): Record<string, unknown> {
const body: Record<string, unknown> = {
input: options.enter ? `${text}\r` : text,
useMux: true,
clientId: options.clientId,
seq: options.seq,
};
if (options.wait !== undefined) body.wait = options.wait;
if (options.waitTimeout !== undefined) body.waitTimeout = options.waitTimeout;
return body;
}
/** Body for the interrupt path: a bare ESC, and nothing appended — ever. */
export function buildInterruptBody(clientId: string, seq: number): Record<string, unknown> {
return { input: '\u001b', useMux: true, clientId, seq };
}
/** `clientId` for this caller: fixed per sending session, so `seq` stays monotonic. */
export function defaultClientId(selfId: string, suffix = ''): string {
return `codeman-agent-cli-${selfId.slice(0, 8)}${suffix ? `-${suffix}` : ''}`;
}
/**
* Strip a terminal buffer for humans: the shared ANSI strip (CSI, OSC such as window
* titles, keypad modes) plus the charset designators (`ESC ( B`) it leaves in.
*/
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return stripAnsiSequences(text).replace(/\x1b[()][AB0]/g, '');
}
/** Parse a positive-integer option (`--timeout` ms, `--tail` bytes); the server rejects anything else. */
export function parsePositiveInt(raw: string | undefined, fallback: number, flag = '--timeout'): number {
if (raw === undefined) return fallback;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${flag} must be a positive integer, got "${raw}"`);
}
return n;
}
/**
* Exit code for a wait result: matched or a signal → ok, `exit` or `ended` → dead,
* timeout → timeout. `ended` is checked BEFORE the happy paths: a worker that dies
* during `--until stop` comes back as `ended:true, signal:null` (the registry only
* satisfies waiters that listed `exit`, then cancels the rest), and a `--match` on
* a dead worker as `ended:true, matched:false` — both are "dead", never "done".
*/
export function waitExitCode(wait: WaitResult | undefined): number {
if (!wait) return EXIT.error;
if (wait.signal === 'exit' || wait.ended) return EXIT.dead;
if (wait.timedOut) return EXIT.timeout;
if (wait.matched === false) return EXIT.timeout;
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// HTTP
// ─────────────────────────────────────────────────────────────────────────────
export interface ApiEnvelope<T = unknown> {
success: boolean;
data?: T;
error?: string;
errorCode?: string;
}
export interface ApiResponse<T = unknown> {
status: number;
/** Parsed envelope, or `undefined` when the body was not JSON (auth guards answer in plain text). */
json?: ApiEnvelope<T>;
text: string;
}
export interface WaitResult {
/** The signal that fired, or null when the wait ended without one. */
signal?: string | null;
timedOut?: boolean;
/** The session went away (deleted / torn down / the write failed) before the wait resolved. */
ended?: boolean;
timeoutMs?: number;
until?: string[];
matched?: boolean;
match?: string;
snippet?: string;
immediate?: boolean;
}
export interface RequestOptions {
method: 'GET' | 'POST' | 'DELETE';
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: Record<string, unknown>;
headers?: Record<string, string>;
/** Socket timeout; long-polls pass their own timeout plus headroom. */
timeoutMs?: number;
}
export type ApiRequest = (ctx: AgentContext, options: RequestOptions) => Promise<ApiResponse>;
/** Every request carries these; they are ignored on endpoints that do not read them. */
export function baseHeaders(ctx: AgentContext): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/json',
// Tags sessions this caller spawns as its children (lineage in the web UI).
// Cosmetic, never fails a call. NOT X-Codeman-Agent-Origin: that one marks a case
// directory as deletable agent scratch, so it rides only the spawn request that
// may create one (see agentSpawn), never anything else.
'X-Codeman-Parent-Session': ctx.selfId,
};
const authorization = ctx.auth ? basicAuthHeader(ctx.auth) : undefined;
if (authorization) headers.Authorization = authorization;
return headers;
}
/** The real transport. `rejectUnauthorized:false` because the HTTPS install uses a self-signed cert. */
export const httpRequest: ApiRequest = (ctx, options) => {
const url = new URL(options.path, ctx.apiUrl);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
const bodyText = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers: Record<string, string | number> = { ...baseHeaders(ctx), ...(options.headers ?? {}) };
if (bodyText !== undefined) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(bodyText);
}
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: options.method,
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
timeout: options.timeoutMs ?? 30_000,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8');
let json: ApiEnvelope | undefined;
try {
json = JSON.parse(text) as ApiEnvelope;
} catch {
json = undefined;
}
resolve({ status: res.statusCode ?? 0, json, text });
});
}
);
req.on('timeout', () => req.destroy(new Error(`request timed out after ${options.timeoutMs ?? 30_000} ms`)));
req.on('error', reject);
if (bodyText !== undefined) req.write(bodyText);
req.end();
});
};
/** One line describing a failed response, for humans. Plain-text guards (401/403/429) have no envelope. */
export function describeFailure(res: ApiResponse): string {
if (res.json && !res.json.success) {
return `${res.json.errorCode ?? 'ERROR'}: ${res.json.error ?? 'request failed'} (HTTP ${res.status})`;
}
const text = res.text.trim().split('\n')[0] ?? '';
if (res.status === 401)
return `HTTP 401 ${text}: the server wants a password (CODEMAN_PASSWORD, or the data dir's .env)`;
return `HTTP ${res.status}${text ? ` ${text}` : ''}`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────────────────────
export interface AgentIo {
out: (line: string) => void;
err: (line: string) => void;
}
export interface AgentDeps {
ctx: AgentContext;
request: ApiRequest;
io: AgentIo;
json: boolean;
/** Clock for `seq`; injectable so tests are deterministic. */
now?: () => number;
}
/** Print `data` as JSON (the `--json` path) — always the envelope's `data`, never a reshaped copy. */
function emitJson(deps: AgentDeps, data: unknown): void {
deps.io.out(JSON.stringify(data, null, 2));
}
function fail(deps: AgentDeps, message: string, code: number = EXIT.error): number {
if (deps.json) {
deps.io.out(JSON.stringify({ success: false, error: message }));
} else {
deps.io.err(palette.err(`${GLYPH.fail} ${message}`));
}
return code;
}
interface SessionRow {
id: string;
name?: string;
mode?: string;
status?: string;
workingDir?: string;
pid?: number | null;
parentSessionId?: string | null;
}
/** A full session id (the only form the routes accept); `ls` prints the 8-char prefix. */
const FULL_ID_LENGTH = 36;
/**
* Shortest prefix that may name a session: the 8-char form `ls` prints, and the floor
* the server's own resolver uses (`PARENT_SESSION_ID_MIN_PREFIX`, route-helpers.ts).
*/
export const MIN_ID_PREFIX_LENGTH = 8;
/**
* Turn the id a human typed into the one the routes accept. `ls` prints 8-char
* prefixes and the routes answer 404 to those (measured live), so anything shorter
* than a full id resolves through the session list; an ambiguous prefix refuses
* rather than picking one. Below 8 characters it refuses before the list: "unique"
* means nothing for `9` — it names whatever session happens to be alone with that
* first character, and `rm`/`send` would act on it.
*/
export async function resolveSessionId(
deps: AgentDeps,
id: string
): Promise<{ id: string } | { error: string; code: number }> {
if (!id) return { error: 'refusing: empty session id', code: EXIT.refused };
if (id.length < MIN_ID_PREFIX_LENGTH) {
return {
error: `refusing: "${id}" is shorter than ${MIN_ID_PREFIX_LENGTH} characters — use the 8-character id \`agent ls\` prints, or the full id`,
code: EXIT.refused,
};
}
if (id.length >= FULL_ID_LENGTH) return { id };
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return { error: describeFailure(res), code: EXIT.error };
const matches = ((res.json.data as SessionRow[] | undefined) ?? []).filter((s) => s.id.startsWith(id));
if (matches.length === 1) return { id: matches[0].id };
if (matches.length === 0) return { error: `no session starts with "${id}" (see \`agent ls\`)`, code: EXIT.error };
return { error: `"${id}" is ambiguous: ${matches.map((s) => s.id.slice(0, 13)).join(', ')}`, code: EXIT.error };
}
/** `agent ls` — every session the caller can see, self marked. */
export async function agentLs(deps: AgentDeps): Promise<number> {
const res = await deps.request(deps.ctx, { method: 'GET', path: '/api/v1/sessions' });
if (!res.json?.success) return fail(deps, describeFailure(res));
const sessions = (res.json.data as SessionRow[] | undefined) ?? [];
if (deps.json) {
emitJson(
deps,
sessions.map((s) => ({ ...s, self: isSelfSession(deps.ctx.selfId, s.id) }))
);
return EXIT.ok;
}
if (sessions.length === 0) {
deps.io.out(palette.muted('(no sessions)'));
return EXIT.ok;
}
const rows = sessions.map((s) => [
isSelfSession(deps.ctx.selfId, s.id) ? '*' : ' ',
s.id.slice(0, 8),
s.mode ?? '?',
s.status ?? '?',
s.name || s.workingDir || '',
]);
deps.io.out(table([[' ', 'ID', 'MODE', 'STATUS', 'NAME'], ...rows], { gap: 2 }));
deps.io.out(
palette.muted(`* = this session (${deps.ctx.selfId.slice(0, 8)}). status is a UI hint, never a sync signal.`)
);
return EXIT.ok;
}
export interface SpawnOptions {
caseName: string;
mode: string;
name?: string;
/** Wait for the composer before returning, where the registry gives the mode a ready mark. */
ready: boolean;
timeoutMs: number;
}
/**
* Agent-scratch label for a case directory a spawn CREATES (the server applies it only
* when quick-start makes the directory). The Add Case UI offers a recursive delete for
* such directories, so this header must never ride any other request: mislabelling a
* real repo there is the one failure in this area that costs actual work.
*/
export const AGENT_ORIGIN_HEADER = { 'X-Codeman-Agent-Origin': 'codeman-agent-cli' } as const;
/**
* What the mode's TUI draws once its composer can take a prompt, from the CLI registry
* (`capabilities.composerReadyMark`); undefined means the mode has no readiness wait.
*/
export function composerReadyMark(mode: string): string | undefined {
return getCli(mode)?.capabilities.composerReadyMark;
}
/** `agent spawn` — quick-start with lineage, then the readiness ladder where the mode has one. */
export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promise<number> {
const body: Record<string, unknown> = {
caseName: options.caseName,
mode: options.mode,
parentSessionId: deps.ctx.selfId,
};
if (options.name) body.sessionName = options.name;
const res = await deps.request(deps.ctx, {
method: 'POST',
path: '/api/v1/quick-start',
body,
headers: { ...AGENT_ORIGIN_HEADER },
});
const data = res.json?.data as { sessionId?: string; caseName?: string; casePath?: string } | undefined;
if (!res.json?.success || !data?.sessionId) return fail(deps, describeFailure(res));
const sid = data.sessionId;
let ready: boolean | undefined;
let readinessError: string | undefined;
let dead = false;
const mark = composerReadyMark(options.mode);
if (options.ready && mark) {
const wait = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`,
query: { match: mark, from: 'buffer', timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A failed readiness call (waiter cap, 400, network) is its own error, not "the
// composer never showed up": report the real reason instead of the trust-dialog hint.
if (!wait.json?.success) readinessError = describeFailure(wait);
else {
const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait;
// A worker that died while we waited is exit 3 like every other wait, not a
// "composer not seen" timeout that sends the caller looking for a dialog.
dead = waitExitCode(result) === EXIT.dead;
ready = !dead && Boolean(result?.matched);
}
}
if (deps.json) {
emitJson(deps, { ...data, ready, readinessError });
} else {
// Human lines go to stderr so `SID=$(codeman agent spawn …)` captures the id alone.
const say = (line: string) => deps.io.err(line);
say(palette.ok(`${GLYPH.ok} spawned ${sid} (${options.mode}, case ${data.caseName ?? options.caseName})`));
if (ready === true) say(palette.muted(' composer up: the worker can take a prompt'));
if (dead) say(palette.err(`${GLYPH.fail} the worker exited during the readiness wait`));
if (ready === false && !dead) {
say(
palette.warn(
`${GLYPH.warn} composer not seen within ${options.timeoutMs} ms — read \`agent read ${sid.slice(0, 8)} --tail 2000\` before sending (a startup dialog?)`
)
);
}
if (readinessError) say(palette.err(`${GLYPH.fail} readiness check failed: ${readinessError}`));
if (ready === undefined && !readinessError && options.ready) {
say(
palette.muted(
` ${options.mode} has no readiness mark; give it a moment, then use --match markers to synchronize`
)
);
}
deps.io.out(sid);
}
if (readinessError) return EXIT.error;
if (dead) return EXIT.dead;
return ready === false ? EXIT.timeout : EXIT.ok;
}
export interface SendOptions {
id: string;
text: string;
enter: boolean;
/** `undefined` = fire-and-forget; `true` = default signal set; string = comma list. */
wait?: string | true;
timeoutMs?: number;
clientId?: string;
seq?: number;
}
/** `agent send` — printable text plus `\r`, exactly-once, optionally blocking on end of turn. */
export async function agentSend(deps: AgentDeps, options: SendOptions): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) {
return fail(deps, `refusing: ${options.id} is me — typing into my own composer is not a message`, EXIT.refused);
}
const refusal = inputRefusal(options.text);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const body = buildSendBody(options.text, {
enter: options.enter,
clientId: options.clientId ?? defaultClientId(deps.ctx.selfId),
seq: options.seq ?? (deps.now ?? Date.now)(),
wait: options.wait,
waitTimeout: options.wait !== undefined ? options.timeoutMs : undefined,
});
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
timeoutMs: (options.timeoutMs ?? 60_000) + 10_000,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { delivered?: boolean; duplicate?: boolean; buffered?: boolean; dropped?: boolean; wait?: WaitResult }
| undefined;
if (deps.json) emitJson(deps, data ?? {});
// Fire-and-forget to a remote session whose host is asleep (wake-on-LAN): the server
// holds the chunk and types it once the pane is back (`buffered`), or the chunk was
// over the wake buffer's cap and is gone (`dropped`). The seq is spent either way,
// so a retry needs a new one (the default, the clock, gives it that).
if (data?.dropped) {
if (!deps.json) {
deps.io.err(
palette.err(
`${GLYPH.fail} dropped: ${target.id}'s host is waking and its input buffer is full — nothing will be typed; send again once it is back`
)
);
}
return EXIT.error;
}
// `delivered:false` without `duplicate` is the route's "the bytes went nowhere":
// the PTY exited or send-keys hit a dead pane. The field exists so a client does not
// say "wait longer" when the truth is "restart the worker" — so it is a failure here.
if (data?.delivered === false && !data.duplicate) {
if (!deps.json) {
deps.io.err(
palette.err(`${GLYPH.fail} not delivered: ${target.id} has no live worker (pane exited) — restart it`)
);
}
return EXIT.dead;
}
if (!deps.json) {
const noEnter = options.enter ? '' : ' (no Enter)';
if (data?.duplicate) {
deps.io.out(palette.warn(`${GLYPH.warn} duplicate (clientId/seq already applied): nothing typed`));
} else if (data?.buffered) {
deps.io.out(
palette.ok(
`${GLYPH.ok} buffered for ${target.id}${noEnter}: its host is asleep; Codeman is waking it and types this once the pane is back`
)
);
} else if (data?.delivered === true) {
deps.io.out(palette.ok(`${GLYPH.ok} delivered to ${target.id}${noEnter}`));
} else {
// Fire-and-forget answers before the write, so there is no delivery report here.
deps.io.out(palette.ok(`${GLYPH.ok} accepted for ${target.id}${noEnter} (no delivery report without --wait)`));
}
if (data?.wait) deps.io.out(describeWait(data.wait));
}
if (options.wait === undefined) return EXIT.ok;
return waitExitCode(data?.wait);
}
function describeWait(wait: WaitResult): string {
if (wait.signal === 'exit') return palette.err(`${GLYPH.fail} the session exited`);
if (wait.ended) {
return palette.err(
`${GLYPH.fail} the wait ended without an answer: the session went away (dead worker, deleted, or nothing was written)`
);
}
// A timeout is a 200 with `timedOut`, an answer rather than a failure: exit 2 says it,
// so the line stays neutral instead of looking like an error to whoever reads the log.
if (wait.timedOut) return palette.muted(`timed out after ${wait.timeoutMs ?? '?'} ms (exit 2)`);
if (wait.matched !== undefined) {
return wait.matched
? palette.ok(`${GLYPH.ok} matched "${wait.match}"${wait.snippet ? `: ${wait.snippet}` : ''}`)
: palette.muted('not matched (exit 2)');
}
return palette.ok(
`${GLYPH.ok} signal: ${wait.signal}${wait.immediate ? ' (immediate: current state, not a transition)' : ''}`
);
}
export interface WaitOptions {
id: string;
until?: string;
match?: string;
from?: 'buffer' | 'now';
fresh?: boolean;
nocase?: boolean;
timeoutMs: number;
}
/** `agent wait` — a signal (`--until`) or a literal output marker (`--match`). */
export async function agentWait(deps: AgentDeps, options: WaitOptions): Promise<number> {
if (options.until && options.match)
return fail(deps, 'use either --until <signals> or --match <marker>, not both', EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
const res = options.match
? await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait-output`,
query: {
match: options.match,
from: options.from ?? 'buffer',
nocase: options.nocase ? 1 : undefined,
timeout: options.timeoutMs,
},
timeoutMs: options.timeoutMs + 10_000,
})
: await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/wait`,
query: { until: options.until, fresh: options.fresh ? 1 : undefined, timeout: options.timeoutMs },
timeoutMs: options.timeoutMs + 10_000,
});
// A 400 here is the server saying "this mode has no such signal" (until=stop on an
// external CLI). Passed through, never papered over: the marker path is the answer.
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as { wait?: WaitResult; status?: string; limitPaused?: boolean } | undefined;
if (deps.json) {
emitJson(deps, data ?? {});
} else if (data?.wait) {
deps.io.out(describeWait(data.wait));
if (data.limitPaused)
deps.io.out(palette.warn(`${GLYPH.warn} session is paused on a usage limit; a timeout is expected`));
}
return waitExitCode(data?.wait);
}
export interface ReadOptions {
id: string;
/** Bytes of raw terminal to fetch; ANSI is stripped for humans. */
tail?: number;
/** Whole conversation (`context=full`) instead of the last assistant message. */
full?: boolean;
}
/** `agent read` — the last answer (the route picks the transcript reader or the pane segmenter) or a terminal tail. */
export async function agentRead(deps: AgentDeps, options: ReadOptions): Promise<number> {
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const sid = encodeURIComponent(target.id);
if (options.tail !== undefined) {
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/terminal`,
query: { tail: options.tail },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const buffer = (res.json.data as { terminalBuffer?: string } | undefined)?.terminalBuffer ?? '';
if (deps.json) emitJson(deps, res.json.data);
else deps.io.out(stripAnsi(buffer));
return EXIT.ok;
}
const res = await deps.request(deps.ctx, {
method: 'GET',
path: `/api/v1/sessions/${sid}/last-response`,
query: { context: options.full ? 'full' : undefined },
});
if (!res.json?.success) return fail(deps, describeFailure(res));
const data = res.json.data as
| { text?: string; timestamp?: string; messages?: Array<{ role: string; text: string }> }
| undefined;
if (deps.json) {
emitJson(deps, data ?? {});
return EXIT.ok;
}
if (options.full && data?.messages) {
for (const m of data.messages) deps.io.out(`${palette.emph(m.role)}: ${m.text}`);
return EXIT.ok;
}
const text = data?.text ?? '';
if (!text) {
deps.io.err(
palette.muted('(empty: nothing answered yet, or nothing the server could segment as an answer; try --tail 3000)')
);
return EXIT.ok;
}
deps.io.out(text);
return EXIT.ok;
}
/** `agent interrupt` — a bare ESC keypress, no Enter, conversation intact. */
export async function agentInterrupt(deps: AgentDeps, options: { id: string }): Promise<number> {
if (isSelfSession(deps.ctx.selfId, options.id)) return fail(deps, `refusing: ${options.id} is me`, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
const body = buildInterruptBody(defaultClientId(deps.ctx.selfId, 'interrupt'), (deps.now ?? Date.now)());
const res = await deps.request(deps.ctx, {
method: 'POST',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}/input`,
body,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else
deps.io.out(
palette.ok(
`${GLYPH.ok} ESC sent to ${target.id} — one Esc does not always land; read the tail before the next prompt`
)
);
return EXIT.ok;
}
/** `agent rm` — delete a session that is provably not this one. */
export async function agentRm(deps: AgentDeps, options: { id: string }): Promise<number> {
const refusal = deleteRefusal(deps.ctx.selfId, options.id);
if (refusal) return fail(deps, refusal, EXIT.refused);
const target = await resolveSessionId(deps, options.id);
if ('error' in target) return fail(deps, target.error, target.code);
// The guard again on the RESOLVED id: a prefix that is not me can still resolve
// to me only if the list is lying, but a delete is the one call worth the paranoia.
const resolvedRefusal = deleteRefusal(deps.ctx.selfId, target.id);
if (resolvedRefusal) return fail(deps, resolvedRefusal, EXIT.refused);
const res = await deps.request(deps.ctx, {
method: 'DELETE',
path: `/api/v1/sessions/${encodeURIComponent(target.id)}`,
});
if (!res.json?.success) return fail(deps, describeFailure(res));
if (deps.json) emitJson(deps, res.json.data ?? {});
else deps.io.out(palette.ok(`${GLYPH.ok} deleted ${target.id} (its case directory stays on disk)`));
return EXIT.ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Commander wiring
// ─────────────────────────────────────────────────────────────────────────────
const DEFAULT_WAIT_MS = 60_000;
/** Build deps from the live environment; the guard's message is the only thing a non-session caller sees. */
function liveDeps(json: boolean): AgentDeps | undefined {
try {
return {
ctx: resolveAgentContext(),
request: httpRequest,
json,
io: { out: (line) => console.log(line), err: (line) => console.error(line) },
};
} catch (err) {
if (err instanceof AgentGuardError) {
console.error(palette.err(`${GLYPH.fail} ${err.message}`));
return undefined;
}
throw err;
}
}
/** Run a verb with the live transport and turn its exit code into the process exit. */
async function run(json: boolean, verb: (deps: AgentDeps) => Promise<number>): Promise<void> {
const deps = liveDeps(json);
if (!deps) {
process.exitCode = EXIT.refused;
return;
}
try {
process.exitCode = await verb(deps);
} catch (err) {
console.error(palette.err(`${GLYPH.fail} ${getErrorMessage(err)}`));
process.exitCode = EXIT.error;
}
}
/** Register `codeman agent …` on the program. */
export function registerAgentCommands(program: Command): Command {
const agent = program
.command('agent')
.description('Talk to other sessions from inside one (any CLI mode): list, spawn, send, wait, read, interrupt, rm');
agent
.command('ls')
.alias('list')
.description('List sessions; * marks this one')
.option('--json', 'Machine-readable output')
.action((options: { json?: boolean }) => run(Boolean(options.json), agentLs));
agent
.command('spawn <case>')
.description(
'Start a worker session in a case (created if missing) and wait for its composer where the mode draws one'
)
.option('-m, --mode <mode>', 'Run mode id, as the Run menu names it', 'claude')
.option('-n, --name <name>', 'Session name shown in the UI')
.option('--no-ready', 'Return as soon as the session exists, without the readiness wait')
.option('-t, --timeout <ms>', 'Readiness budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(caseName: string, options: { mode: string; name?: string; ready: boolean; timeout?: string; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentSpawn(deps, {
caseName,
mode: options.mode,
name: options.name,
ready: options.ready,
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
})
)
);
agent
.command('send <id> <text...>')
.description(
'Type a prompt into another session and press Enter (ONE quoted argument, printable text only; a prompt that starts with "-" goes after --: send <id> -- "- fix the bug")'
)
.option('-w, --wait', 'Block until end of turn (the default signal set; see --until)')
.option('-u, --until <signals>', 'Signals to wait for, comma list such as stop,exit (implies --wait)')
.option('-t, --timeout <ms>', 'Wait budget in ms (with --wait)', String(DEFAULT_WAIT_MS))
.option('--no-enter', 'Type the text without submitting it')
.option('--client-id <id>', 'Exactly-once tag (default: one per calling session)')
.option(
'--seq <n>',
'Sequence number for the tag (default: the current epoch ms). Must stay monotonic per client id: a reused or lower value is a silent duplicate, nothing is typed'
)
.option('--json', 'Machine-readable output')
.action(
(
id: string,
words: string[],
options: {
wait?: boolean;
until?: string;
timeout?: string;
enter: boolean;
clientId?: string;
seq?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) => {
const prompt = sendPromptFromArgs(words);
if ('error' in prompt) return Promise.resolve(fail(deps, prompt.error, EXIT.refused));
return agentSend(deps, {
id,
text: prompt.text,
enter: options.enter,
wait: options.until ?? (options.wait ? true : undefined),
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
clientId: options.clientId,
seq: options.seq === undefined ? undefined : parsePositiveInt(options.seq, 1, '--seq'),
});
})
);
agent
.command('wait <id>')
.description(
'Block until a signal (--until) or an output marker (--match) — timeout exits 2, a dead worker exits 3'
)
.option(
'-u, --until <signals>',
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
)
.option(
'-m, --match <marker>',
'Literal substring to wait for in the output (ANSI-stripped, no regex). The echo of your own prompt is output too, so never put the marker verbatim in the prompt: ask for it in halves ("print WORKDONE followed by _4711") and wait on the joined form (WORKDONE_4711)'
)
.option('--from <where>', 'buffer (scan existing output first, the default) or now', 'buffer')
.option('--nocase', 'Case-insensitive --match')
.option('--fresh', 'Require an actual transition (--until only)')
.option('-t, --timeout <ms>', 'Budget in ms', String(DEFAULT_WAIT_MS))
.option('--json', 'Machine-readable output')
.action(
(
id: string,
options: {
until?: string;
match?: string;
from: string;
nocase?: boolean;
fresh?: boolean;
timeout?: string;
json?: boolean;
}
) =>
run(Boolean(options.json), (deps) =>
agentWait(deps, {
id,
until: options.until,
match: options.match,
from: options.from === 'now' ? 'now' : 'buffer',
nocase: options.nocase,
fresh: options.fresh,
timeoutMs: parsePositiveInt(options.timeout, DEFAULT_WAIT_MS),
})
)
);
agent
.command('read <id>')
.description("Print a session's last answer (as the server reads it for that mode) or, with --tail, its terminal")
.option('--tail <bytes>', 'Raw terminal tail in bytes, ANSI stripped (works in every mode)')
.option('--full', 'The whole conversation instead of the last assistant message')
.option('--json', 'Machine-readable output')
.action((id: string, options: { tail?: string; full?: boolean; json?: boolean }) =>
run(Boolean(options.json), (deps) =>
agentRead(deps, {
id,
tail: options.tail === undefined ? undefined : parsePositiveInt(options.tail, 3000, '--tail'),
full: options.full,
})
)
);
agent
.command('interrupt <id>')
.description('Send a bare ESC to stop the current turn (the conversation survives; deleting would not)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) =>
run(Boolean(options.json), (deps) => agentInterrupt(deps, { id }))
);
agent
.command('rm <id>')
.description('Delete any session except this one (refuses your own id)')
.option('--json', 'Machine-readable output')
.action((id: string, options: { json?: boolean }) => run(Boolean(options.json), (deps) => agentRm(deps, { id })));
return agent;
}
+8 -28
View File
@@ -15,9 +15,11 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { readCodemanCredentials } from './codeman-credentials.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { registerAgentCommands } from './cli-agent.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
@@ -42,32 +44,8 @@ function makeAttachmentMagicLink(filePath: string): string {
return `codeman://attach?path=${encodeURIComponent(filePath)}`;
}
function readCodemanEnv(): Record<string, string> {
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
} catch {
return {};
}
}
async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise<boolean> {
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const { username, password } = readCodemanCredentials();
const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl);
const body = JSON.stringify({ path: filePath });
const transport = url.protocol === 'https:' ? https : http;
@@ -252,6 +230,10 @@ skillCmd
}
});
// ============ Agent Commands (session-to-session, any CLI mode) ============
registerAgentCommands(program);
// ============ Session Commands ============
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
@@ -641,9 +623,7 @@ function probeWebServerAt(base: string): Promise<WebServerProbe | null> {
} catch {
return Promise.resolve(null);
}
const envFile = readCodemanEnv();
const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD;
const { username, password } = readCodemanCredentials();
const transport = url.protocol === 'https:' ? https : http;
const headers: Record<string, string> = { Accept: 'application/json' };
if (password) {
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Credentials for a client of this Codeman instance's own API.
*
* Env first, the data dir's `.env` as the fallback — the hand-authored file
* `codeman attach`, `codeman tui` and `codeman agent` all read. One reader, so the
* three clients cannot drift on quoting, comments or the default username.
*
* @module codeman-credentials
*/
import { readFileSync } from 'node:fs';
import { dataPath } from './config/instance.js';
export interface CodemanCredentials {
username: string;
/** Absent when no password is configured (or only the server's environment has it). */
password?: string;
}
/**
* Parse a `KEY=value` env file: blank lines and `#` comments skipped, an `export `
* prefix tolerated (the file is hand-authored, often sourced by a shell too), one
* layer of matching quotes stripped, anything that is not an assignment ignored.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
/** The data dir's `.env`, parsed. Absent or unreadable means `{}`. */
export function readCodemanEnvFile(envFilePath: string = dataPath('.env')): Record<string, string> {
try {
return parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
return {};
}
}
/**
* The lookup order every client uses, per field: the environment, then the `.env`
* file, then (username only) `admin`. Pure, so a caller with its own environment
* object (`codeman agent`'s guard takes one for testability) gets the same answer.
*/
export function credentialsFrom(env: NodeJS.ProcessEnv, fileEnv: Record<string, string>): CodemanCredentials {
const username = env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
/**
* Credentials for the API. No password means no auth is configured, or the user has
* it only in the server's environment, in which case the API answers 401.
*/
export function readCodemanCredentials(
envFilePath: string = dataPath('.env'),
env: NodeJS.ProcessEnv = process.env
): CodemanCredentials {
return credentialsFrom(env, readCodemanEnvFile(envFilePath));
}
/** `Authorization` header value, or undefined when there is no password to send. */
export function basicAuthHeader(credentials: CodemanCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
+3
View File
@@ -320,6 +320,8 @@ const capabilitiesSchema = z
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
// Literal text matched by a `wait-output` long-poll, never compiled as a regex.
composerReadyMark: z.string().min(1).max(64).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
@@ -449,6 +451,7 @@ const capabilitiesSchema = z
'codex-toml',
'opencode-json',
'antigravity-json',
'copilot-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
+5
View File
@@ -218,6 +218,9 @@ const CLAUDE: CliEntry = {
// in them, so a copy can drop two and paste flush. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
// The composer's own hint text (`⏵⏵ … (shift+tab to cycle)`), not `❯`, which the
// trust dialog's selected row also carries. Measured by the agent skill's spawn_worker.
composerReadyMark: 'shift+tab',
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
// footer, because tmux repaints partially and only one of the two may land in a chunk.
@@ -1342,6 +1345,8 @@ const DEEPSEEK: CliEntry = {
// supervisor and Codeman is that supervisor. 'supervised' rather than 'always' because
// the session can disarm the bridge, and docker/remote cannot reach it at all.
hooks: 'supervised',
// dsh's composer glyph, drawn once the harness TUI can take a prompt.
composerReadyMark: '❯',
transcript: 'deepseek-zstd',
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
+18 -1
View File
@@ -105,7 +105,13 @@ export type ModelConfigResolverName = 'deepseek-route';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
export type McpConfigFormat =
| 'claude-json'
| 'gemini-json'
| 'codex-toml'
| 'opencode-json'
| 'antigravity-json'
| 'copilot-json';
export interface CliLaunch {
params: Record<string, ParamSpec>;
@@ -416,6 +422,17 @@ export interface CliCapabilities {
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
/**
* Literal text the TUI draws once its composer can take a prompt — what `codeman agent
* spawn` waits for (a `wait-output` match) before it calls a worker ready.
*
* Deliberately NOT `workDetect.promptGlyph`: claude's `❯` also marks the selected row
* of its workspace-trust dialog, which is exactly the screen a readiness wait must not
* mistake for a composer, so claude declares its composer's own hint text instead.
* Absent means no readiness wait: a spawn returns as soon as the session exists, and
* the caller synchronizes on `wait-output` markers.
*/
composerReadyMark?: string;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
+4
View File
@@ -495,6 +495,10 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
SSH_ASKPASS_REQUIRE: 'never',
DISPLAY: '',
GCM_INTERACTIVE: 'never',
// classifyGitFailure() matches git's ENGLISH stderr; a German or French locale would
// turn a missing ref into a generic FAILED (422 instead of 400).
LC_ALL: 'C',
LANG: 'C',
GIT_SSH_COMMAND:
base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
};
+6 -2
View File
@@ -14,6 +14,7 @@ import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
import { UPLOAD_DIR_NAMES } from './web/paste-image-gc.js';
// ========== Types ==========
@@ -157,12 +158,15 @@ export class ImageWatcher extends EventEmitter {
// Watch all subdirectories (images may be saved in src/, assets/, etc.)
// Ignore common heavy directories for performance
ignored: (path: string) => {
// Skip node_modules, .git, and other heavy directories
// Skip node_modules, .git, and other heavy directories, and Codeman's
// own upload folders: a pdf the user handed to the agent is not a file
// the agent produced.
if (
path.includes('/node_modules/') ||
path.includes('/.git/') ||
path.includes('/dist/') ||
path.includes('/.next/')
path.includes('/.next/') ||
UPLOAD_DIR_NAMES.some((name) => path.includes(`/${name}/`))
) {
return true;
}
+74
View File
@@ -0,0 +1,74 @@
/**
* @fileoverview MCP sync targets that are not Codeman run modes.
*
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
* config file already exists (an absent tool is reported `absent`, never created), and sort after
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
*
* @module mcp-sync-targets
*/
import { accessSync, constants as fsConstants } from 'node:fs';
import { homedir } from 'node:os';
import { delimiter, join } from 'node:path';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncTarget } from './mcp-sync.js';
export interface McpSyncOnlyTool {
id: string;
label: string;
/** Home-relative default location of the MCP config file. */
path: string;
format: McpConfigFormat;
/** The env var the tool reads to move its home, and the file under it. */
relocation?: { envVar: string; path: string };
/** The executable whose presence on this machine means the tool is installed. */
binary: string;
}
export const MCP_SYNC_ONLY_TOOLS: readonly McpSyncOnlyTool[] = [
{
id: 'copilot',
label: 'GitHub Copilot CLI',
path: '.copilot/mcp-config.json',
format: 'copilot-json',
// COPILOT_HOME replaces ~/.copilot (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
binary: 'copilot',
},
];
/** `name` is an executable file in the server's PATH, `~/.local/bin` or `/usr/local/bin`. */
export function binaryOnPath(name: string, env: Record<string, string | undefined> = process.env): boolean {
const dirs = [
...(env.PATH ?? '').split(delimiter).filter(Boolean),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
];
return dirs.some((dir) => {
try {
accessSync(join(dir, name), fsConstants.X_OK);
return true;
} catch {
return false;
}
});
}
/** The sync-only tools as sync targets, skipping any id the registry already provides. */
export function mcpSyncOnlyTargets(
taken: ReadonlySet<string>,
isInstalled: (binary: string) => boolean = binaryOnPath
): McpSyncTarget[] {
return MCP_SYNC_ONLY_TOOLS.filter((t) => !taken.has(t.id)).map((t) => ({
id: t.id,
label: t.label,
path: t.path,
format: t.format,
...(t.relocation ? { relocation: t.relocation } : {}),
installed: isInstalled(t.binary),
}));
}
+63 -1
View File
@@ -12,7 +12,8 @@
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
* `enabled: false`, antigravity `disabled: true`, Copilot's `disabledMcpServers` in its
* `settings.json`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
@@ -284,6 +285,29 @@ function toOpencode(s: McpServer): Record<string, unknown> {
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
/**
* GitHub Copilot CLI (`copilot mcp add`): `~/.copilot/mcp-config.json`, `mcpServers`. A stdio server is
* `type: "local"`; every entry carries `tools` (`["*"]` = all). Whether a server is switched off is NOT in
* this file: `copilot mcp disable` records the name in `settings.json` beside it (`disabledMcpServers`).
*/
function fromCopilot(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
if ((raw.type === 'http' || raw.type === 'sse') && typeof raw.url === 'string') {
return clean({ transport: raw.type, url: raw.url, headers: strMap(raw.headers) });
}
if ((raw.type === undefined || raw.type === 'local' || raw.type === 'stdio') && typeof raw.command === 'string') {
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
}
return null;
}
function toCopilot(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return { tools: ['*'], type: 'local', command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}) };
}
return { tools: ['*'], type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
}
interface JsonDialect {
/** Key holding the server table. */
key: string;
@@ -291,12 +315,23 @@ interface JsonDialect {
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
/**
* A file beside the config that lists the names of servers the user switched off (the switch is
* not stored on the server entry). Read, never written.
*/
disabledIn?: { file: string; key: string };
}
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'copilot-json': {
key: 'mcpServers',
from: fromCopilot,
to: toCopilot,
disabledIn: { file: 'settings.json', key: 'disabledMcpServers' },
},
'opencode-json': {
key: 'mcp',
from: fromOpencode,
@@ -558,6 +593,32 @@ function resolveFile(
return { file: join(dir, rel.path) };
}
/**
* Mark the servers a CLI keeps switched off in a companion file (`JsonDialect.disabledIn`) as
* disabled, so they are not copied. If that file cannot be read as intended the target is
* reported unreadable rather than guessing: a guess could switch a server on everywhere.
*/
async function applyCompanionDisabled(format: McpFormat, file: string, servers: McpServerMap): Promise<void> {
if (format === 'codex-toml') return;
const companion = JSON_DIALECTS[format].disabledIn;
if (!companion) return;
const text = await readText(join(dirname(file), companion.file));
if (text === null || !text.trim()) return;
let doc: unknown;
try {
doc = JSON.parse(text);
} catch {
throw new McpConfigError(
`${companion.file} next to the config is not valid JSON, so which servers are switched off is unknown`
);
}
const list = isRecord(doc) ? doc[companion.key] : undefined;
if (list === undefined) return;
const names = strArr(list);
if (!names) throw new McpConfigError(`"${companion.key}" in ${companion.file} is not a list of names`);
for (const n of names) if (n in servers) servers[n] = { ...servers[n], disabled: true };
}
let applying = false;
/**
@@ -611,6 +672,7 @@ async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported:
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
await applyCompanionDisabled(s.t.format, s.file, parsed.servers);
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
-9
View File
@@ -204,15 +204,6 @@ export interface PaneCaptureOptions {
* rendering it needs the real height to know the frame fits.
*/
capturedGeometry?: { cols: number; rows: number };
/**
* Filled in by the implementation with the number of rows the pane holds in
* scrollback ABOVE the visible frame (tmux `#{history_size}`), read in the
* same query as the geometry. 0 means a full-history capture can return
* nothing beyond the visible frame: a pane in the alternate screen (a
* fullscreen CLI that keeps its transcript itself) never accumulates any.
* Absent when the pane could not be queried.
*/
capturedHistoryLines?: number;
}
/**
+3 -15
View File
@@ -700,12 +700,6 @@ interface PaneCursorGeometry {
rows: number;
cursorX: number;
cursorY: number;
/**
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
* full-history capture can add. Absent when the query did not return it.
* Optional and validated on its own, so a bad value never voids the caret.
*/
historyLines?: number;
}
/**
@@ -722,7 +716,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
return null;
}
const [cursorX, cursorY, cols, rows, historyLines] = raw.split(/\s+/).map((value) => parseInt(value, 10));
const [cursorX, cursorY, cols, rows] = raw.split(/\s+/).map((value) => parseInt(value, 10));
if (
!Number.isFinite(cursorX) ||
!Number.isFinite(cursorY) ||
@@ -735,9 +729,7 @@ export function queryPaneCursor(run: () => string): PaneCursorGeometry | null {
) {
return null;
}
const geometry: PaneCursorGeometry = { cols, rows, cursorX, cursorY };
if (Number.isFinite(historyLines) && historyLines >= 0) geometry.historyLines = historyLines;
return geometry;
return { cols, rows, cursorX, cursorY };
}
/** SGR attributes, which is all `capture-pane -e` emits. */
@@ -3898,7 +3890,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor(() =>
execSync(
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size}'`,
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
);
@@ -3910,10 +3902,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// geometry is reported there for diagnosis rather than for repair. Only
// the caller can see both sizes, so hand it this one.
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
// Same query, no extra tmux call: how much scrollback a full-history pull
// could return. A pane in the alternate screen (fullscreen claude) holds
// none, and the partial-history notice must not promise it.
if (opts && geometry?.historyLines !== undefined) opts.capturedHistoryLines = geometry.historyLines;
if (fullHistory) {
// Without geometry there is no cursor move, so fall back to the old trim.
+9 -47
View File
@@ -48,6 +48,12 @@ import https from 'node:https';
import { hostname as osHostname } from 'node:os';
import { promisify } from 'node:util';
import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js';
import {
basicAuthHeader,
parseEnvFile,
readCodemanCredentials,
type CodemanCredentials,
} from '../codeman-credentials.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { probeServer } from '../daemon-control.js';
import { getErrorMessage } from '../types/api.js';
@@ -281,53 +287,9 @@ export function tuiServerCandidates(env: { apiUrl?: string; port?: string | numb
return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`];
}
/**
* Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank
* lines and `#` comments skipped, one layer of matching quotes stripped.
*/
export function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[match[1]] = value;
}
return result;
}
export interface TuiCredentials {
username: string;
password?: string;
}
/**
* Credentials for the API, env first and the data dir's `.env` as the fallback,
* exactly like the `codeman attach` path. No password means no auth is
* configured (or the user has it only in the server's environment, in which
* case the API answers 401 and `connect()` reports `authRequired`).
*/
export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials {
let fileEnv: Record<string, string> = {};
try {
fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8'));
} catch {
/* absent or unreadable: env-only */
}
const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin';
const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD;
return password ? { username, password } : { username };
}
export function basicAuthHeader(credentials: TuiCredentials): string | undefined {
if (!credentials.password) return undefined;
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// One credential reader for every client of the API (attach, tui, agent).
export { parseEnvFile, readCodemanCredentials, basicAuthHeader };
export type TuiCredentials = CodemanCredentials;
// ─────────────────────────────────────────────────────────────────────────────
// Degraded mode
+105 -30
View File
@@ -1,25 +1,99 @@
/**
* @fileoverview Periodic GC for paste-image files.
* @fileoverview Periodic GC for prompt-upload files, and the one place that
* names the directories they live in.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
* under {workingDir}/.claude-images/. The route only triggers cleanup on
* under {workingDir}/.codeman-uploads/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
* older than MAX_AGE_MS from each live session's image dir on an interval.
* older than MAX_AGE_MS from each live session's upload dirs on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
* image dir.
* upload dir.
*/
import fs from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { join, resolve, sep } from 'node:path';
import { getDataDir } from '../config/instance.js';
import { probePathKind, type PathProbeOptions } from '../utils/bounded-path-probe.js';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
/**
* Where a prompt upload is written, relative to the session's working
* directory. IN the workspace, because that is the only path that resolves
* identically for a local agent and a container (only the workspace is
* bind-mounted, at the same absolute path); hidden, so it stays out of
* `git status` and the agent's view of the repository; FLAT, because a nested
* `<workspace>/.codeman/` is Codeman's own data dir when the workspace is the
* home directory; and self-ignoring, through a `.gitignore` of `*` the route
* writes once.
*/
export const UPLOADS_DIR = '.codeman-uploads';
/** Where uploads landed before the move: written to by nothing, readable for one release. */
export const LEGACY_UPLOADS_DIR = '.claude-images';
/** Every upload dir name, current first. Retiring the legacy one here retires it for every reader. */
export const UPLOAD_DIR_NAMES = [UPLOADS_DIR, LEGACY_UPLOADS_DIR];
/**
* Every directory a session's uploads sit in, current first. Both consumers
* act on what this returns, the hourly sweep and the recursive delete in
* cleanupSession(), so it lists only REAL directories (a link planted by a
* workspace script, `.codeman-uploads -> /other-case/.codeman-uploads`, is
* not one; readdir follows a link to a directory), none that is or contains
* this instance's data dir (a home workspace reaches it under a contrived
* instance name, `CODEMAN_INSTANCE=uploads`, and `CODEMAN_DATA_DIR` can point
* inside one; a directory strictly below the data dir only ever holds uploads
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
* never be collected), and nothing for a remote (SSH) session, whose
* workingDir is the remote path and would name a same-named LOCAL directory
* here. The working directory is a user-chosen path, so it is probed BOUNDED
* first (#516): one on a mount that stopped answering reads `unknown` and is
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
* acting on one path at the user's request, passes `pastCap`. The check is
* made when listing: a same-user process that swaps a listed directory for a
* link afterwards is accepted, since it already writes anywhere this process
* can.
*/
export async function uploadDirs(
session: { workingDir: string; remote?: unknown },
probe: PathProbeOptions = {}
): Promise<string[]> {
if (session.remote) return [];
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
const dataDir = await realDir(getDataDir());
const dirs: string[] = [];
for (const dir of UPLOAD_DIR_NAMES.map((name) => join(session.workingDir, name))) {
if (!(await isRealDir(dir))) continue;
const real = await realDir(dir);
if (real === dataDir || dataDir.startsWith(real + sep)) continue;
dirs.push(dir);
}
return dirs;
}
/** lstat, so a symlink is not a directory, whatever it points at. */
async function isRealDir(p: string): Promise<boolean> {
try {
return (await fs.lstat(p)).isDirectory();
} catch {
return false;
}
}
/** `canonicalDir()` for the listing, which must not block the event loop on a user path. */
async function realDir(dir: string): Promise<string> {
try {
return await fs.realpath(dir);
} catch {
return resolve(dir);
}
}
export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now()
@@ -28,26 +102,27 @@ export async function sweepPasteImagesOnce(
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
const dir = join(session.workingDir, '.claude-images');
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir absent — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
for (const dir of await uploadDirs(session)) {
let entries: string[];
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
entries = await fs.readdir(dir);
} catch {
// best-effort: skip permission/race errors silently
continue; // gone since listed — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
}
}
}
}
@@ -55,7 +130,7 @@ export async function sweepPasteImagesOnce(
}
/**
* The path two sessions must share to share a paste-image dir: the canonical
* The path two sessions must share to share an upload dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
@@ -68,7 +143,7 @@ function canonicalDir(dir: string): string {
}
}
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
/** One session the upload-dir guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
@@ -76,11 +151,11 @@ export interface PasteImageDirUser {
}
/**
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
* Does another live session still use this working directory's upload dirs?
* Deleting a session removes them (`uploadDirs()`) recursively, and several
* sessions routinely share one case directory, so without this check closing
* one session deletes the pasted images a sibling in the same case still
* refers to.
*
* Two kinds of sibling count as live:
*
+210 -98
View File
@@ -717,6 +717,7 @@ class CodemanApp {
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this._tabRailSearch = ''; // rail search box text: in memory only, never persisted
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -767,12 +768,6 @@ class CodemanApp {
// their scrollback can be very large; full history stays available on demand.
// Tracked PER SESSION rather than as a single "first load" flag (issue #205).
this._fullHistoryLoaded = new Set();
// Rows of scrollback tmux reported above the visible frame on this session's
// last capture (`paneHistoryLines`). 0 marks a pane with no history to load
// (a fullscreen CLI in the alternate screen), which a tab switch serves from
// the small `full=1` capture instead of the 1 MiB byte tail: see
// _notePaneHistory and selectSession.
this._paneHistoryLines = new Map(); // Map<sessionId, number>
// Cooldown per session for the scroll-to-top "load more history" re-pull.
this._fullHistoryRepullAt = new Map(); // Map<sessionId, timestamp>
this._fullHistoryRepullInFlight = false;
@@ -1432,6 +1427,13 @@ class CodemanApp {
this.closeTileCountMenu({ refocus: true });
return;
}
// And so does the rail's search box while it holds text: that Escape
// clears the search and nothing else. This listener runs in the capture
// phase, before the box's own onkeydown, so the box cannot claim it there.
if (e.target?.id === 'tabRailSearch' && this._tabRailSearch) {
this.handleTabRailSearchKeydown(e);
return;
}
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
@@ -1818,8 +1820,11 @@ class CodemanApp {
_markDetached(id, on) {
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
// A popped-out session's window owns its PTY size now, so it leaves the
// tile grid (one place per session in this browser tab).
if (on && this._tileGrid?.has(id)) this.removeTile(id);
// tile grid (one place per session in this browser tab). `gone`: it left by
// itself, not by a tile the user removed, so the grid's count stays and the
// ranking fills that cell the next time the grid opens, as when it pops out
// with the grid closed.
if (on && this._tileGrid?.has(id)) this.removeTile(id, { gone: true });
const container = this.$('sessionTabs');
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.classList.toggle('detached', on);
@@ -2743,6 +2748,23 @@ class CodemanApp {
return;
}
// An in-document link (`[Install](#installation)`). The browser must not follow it: with
// `<base href="/">` a bare fragment points at the dashboard's root and would navigate the
// app away. Resolve it inside this rendered document and scroll there (constants.js).
// A fragment that matches nothing is simply ignored, never a navigation.
const fragmentLink = ev.target.closest('a[href^="#"]');
if (fragmentLink && body.contains(fragmentLink)) {
ev.preventDefault();
ev.stopPropagation();
const root = fragmentLink.closest('.rv-text') || body;
const target = window.CodemanMarkdownAnchors?.find(root, fragmentLink.getAttribute('href'));
if (target) {
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
}
return;
}
// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
// (webview-tabs.js). Every other link keeps its new-tab default.
@@ -3199,7 +3221,6 @@ class CodemanApp {
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
this._notePaneHistory?.(sessionId, data);
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
@@ -5750,27 +5771,163 @@ class CodemanApp {
*/
applySidebarFilter(query) {
this._sidebarFilter = (query ?? '').trim().toLowerCase();
this._applyTabListFilter();
}
/**
* The ONE row filter behind both search boxes: the sidebar's filter box and
* the vertical rail's search box (only one of the two hosts the list at a
* time). Classes only, over whatever the last render drew, so grouping, order,
* Alt+N badges and the server layout never move; the matching itself is the
* pure CodemanTabSearch (constants.js).
*
* - Sidebar: name (aria-label) + working directory (title), as it always has.
* - Rail: the NAME only, a web tab's title included (it is a row in the same
* list, and hiding every web tab would make a dashboard unfindable).
*
* A session row with a tab alert (red action or yellow idle, whatever
* tabAlerts holds, the set a collapsed group header surfaces) stays visible
* even when it does not match: a prompt waiting on you is never hidden by a
* view filter. Alerts come and go through renderSessionTabs(), and both
* render paths end here, so nothing else re-runs this for them.
*
* A group or case box left with nothing showing hides with its header, its
* count shows the rows left showing (a kept row included), and the grouped
* tree's roving stop and posinset follow the visible items. A collapsed
* group's rows are not in the DOM at all, which is why the rail search also
* expands the projection (_projectTabGroups). Rows that appear or disappear
* move the rows below them, so the connector lines are redrawn then.
*/
_applyTabListFilter() {
const container = this.$('sessionTabs');
if (!container) return;
const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : '';
const rail = this._tabOrientation() === 'vertical';
const sidebarReachable =
!rail && this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const query = rail ? this._tabRailSearch : sidebarReachable ? this._sidebarFilter : '';
const rows = [...container.querySelectorAll('.session-tab')].map((tab) => ({
key: tab,
text: rail
? this._tabRowSearchName(tab)
: `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`,
section: tab.closest('.tab-layout-group, .tab-cluster'),
// Web tabs carry no alerts; only a session row can be kept.
keep: !tab.dataset.webviewId && !!tab.dataset.id && !!this.tabAlerts?.get(tab.dataset.id),
}));
const result = window.CodemanTabSearch?.filter(rows, query);
if (!result) return;
// Whether anything appeared or disappeared: the rows below it then moved.
let moved = false;
// State headings count the whole group, so they step aside while a filter
// is narrowing the rows under them (styles.css, .tabs-filtering).
container.classList.toggle('tabs-filtering', !!needle);
for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) {
tab.classList.remove('tab-filtered-out');
continue;
if (container.classList.contains('tabs-filtering') !== result.active) {
container.classList.toggle('tabs-filtering', result.active);
moved = true;
}
const setFilteredOut = (el, out) => {
if (el.classList.contains('tab-filtered-out') === out) return;
el.classList.toggle('tab-filtered-out', out);
moved = true;
};
for (const row of rows) setFilteredOut(row.key, result.hidden.has(row.key));
for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) {
const shown = result.counts.get(section) ?? 0;
setFilteredOut(section, result.active && shown === 0);
const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count');
if (!count) continue;
if (count.dataset.total === undefined) count.dataset.total = count.textContent;
const text = result.active ? String(shown) : count.dataset.total;
if (count.textContent !== text) count.textContent = text;
}
const empty = document.getElementById('tabRailSearchEmpty');
const emptyHidden = !(rail && result.active && result.matchCount === 0);
if (empty && empty.hidden !== emptyHidden) {
empty.hidden = emptyHidden;
moved = true;
}
// Lineage and subagent/ultracode connectors are anchored to row positions.
// A render redraws them itself, but a keystroke in either box only toggles
// classes here, so the rows it moved would leave the lines pointing at where
// they were. Only when something moved: an unchanged re-apply at every
// render tail stays free, and the call coalesces with a render's own.
if (moved) this.updateConnectionLines?.();
// Both render paths already set posinset and the roving stop over an
// unfiltered tree, so this second pass only runs while a search hides
// something or right after one changed what shows.
if ((moved || result.active) && container.getAttribute('role') === 'tree') {
const items = this._applyTabTreePositions(container);
const stop = container.querySelector('[role="treeitem"][tabindex="0"]');
if (items.length && !items.includes(stop)) {
this._setTabTreeStop(container, items.find((item) => item.getAttribute('aria-selected') === 'true') || items[0]);
}
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
}
// The count shows visible rows, so it moves with every filter change —
// including keystrokes in the filter box, which call this directly.
this.updateSidebarCount();
}
/** What the rail search matches on a row: a session's name, a web tab's title. */
_tabRowSearchName(tab) {
if (tab.dataset.webviewId) return this.webviews?.get(tab.dataset.webviewId)?.name || '';
return tab.querySelector('.tab-name')?.dataset.fullName || '';
}
/** True while the vertical rail's search box is narrowing the list. */
_tabRailSearchActive() {
return this._tabOrientation() === 'vertical' && !!window.CodemanTabSearch?.needle(this._tabRailSearch);
}
/**
* The rail search box's input handler. In-memory only: never persisted, never
* sent anywhere. Starting or ending a search re-renders once when it changes
* what a collapsed group hides (the projection ignores collapse while
* searching); every other keystroke only re-applies the row classes.
*/
setTabRailSearch(value) {
this._tabRailSearch = typeof value === 'string' ? value : '';
const clear = document.getElementById('tabRailSearchClear');
if (clear) clear.hidden = this._tabRailSearch.length === 0;
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
else this._applyTabListFilter();
}
/** Clear button (and Escape): empty the box, restore the list, keep focus in the box. */
clearTabRailSearch() {
const input = document.getElementById('tabRailSearch');
if (input) input.value = '';
this.setTabRailSearch('');
input?.focus();
}
/**
* Escape in a box that holds text clears the search and nothing else. The
* global key handler (setupEventListeners) runs in the CAPTURE phase, before
* the box's inline onkeydown, so it is the one that routes the key here and
* returns before its close-every-panel branch; stopping propagation from the
* inline handler would come too late. An empty box leaves Escape to it, and
* an Escape that cancels an IME composition is the IME's.
*/
handleTabRailSearchKeydown(event) {
if (event.key !== 'Escape' || event.isComposing || !this._tabRailSearch) return;
event.preventDefault();
event.stopPropagation();
this.clearTabRailSearch();
}
/**
* Forget the search without rendering: the list is leaving the rail
* (applyTabOrientation), and the render that follows draws it unfiltered.
*/
_resetTabRailSearch() {
this._tabRailSearch = '';
const input = document.getElementById('tabRailSearch');
if (input) input.value = '';
const clear = document.getElementById('tabRailSearchClear');
if (clear) clear.hidden = true;
const empty = document.getElementById('tabRailSearchEmpty');
if (empty) empty.hidden = true;
}
// ═══════════════════════════════════════════════════════════════
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
// ═══════════════════════════════════════════════════════════════
@@ -6143,6 +6300,20 @@ class CodemanApp {
};
}
/**
* The session's tab row when it is painted, else null: not rendered (a
* collapsed group) or hidden by the rail search or the sidebar filter. A
* display:none row still answers getBoundingClientRect() with an all-zero
* rect, which is truthy, so a connector, a spawn or a genie measured from it
* would start at the viewport's top-left corner. Every floating window that
* anchors to its parent tab measures through this.
*/
_paintedSessionTab(sessionId) {
if (!sessionId) return null;
const tab = document.querySelector(`.session-tab[data-id="${sessionId}"]`);
return tab && tab.getClientRects().length > 0 ? tab : null;
}
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
_tabConnectorPath(anchor, winRect) {
if (anchor.vertical) {
@@ -6731,7 +6902,9 @@ class CodemanApp {
// every agent CLI, claude included, shows its logo through PR #532's
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
// headers and the Run menus draw the same mark. An id with no logo rule (a
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
const tabModeHtml = mode === 'shell'
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
@@ -6987,6 +7160,8 @@ class CodemanApp {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
// A group the search emptied is hidden whole, header included.
if (section.classList.contains('tab-filtered-out')) continue;
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
@@ -7390,7 +7565,10 @@ class CodemanApp {
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
collapsedGroupIds: [...this.collapsedTabGroupIds],
// A rail search shows matches inside collapsed groups too, so it projects
// every group open. The stored per-device collapse state is untouched and
// applies again as soon as the search is cleared.
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
@@ -7411,6 +7589,9 @@ class CodemanApp {
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
// Every group is drawn open while the rail search runs; a toggle then would
// change what the user sees only after the search is cleared.
if (this._tabRailSearchActive()) return false;
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
@@ -8559,7 +8740,6 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -8697,9 +8877,6 @@ class CodemanApp {
// Set once a full-history pull has been refused as a downgrade: the
// browser holds more than the server can return, so there is no more.
exhausted: !!payload.exhausted,
// tmux scrollback above the frame (null = not reported). 0 is a pane with
// nothing a pull could add, which hides the notice outright.
paneHistoryLines: payload.paneHistoryLines ?? null,
});
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
@@ -8707,45 +8884,9 @@ class CodemanApp {
/** Drop banner state for a session that is going away. */
_clearHistoryTruncation(sessionId) {
this._historyTruncation?.delete(sessionId);
this._historyNoticeDismissed?.delete(sessionId);
this._paneHistoryLines?.delete(sessionId);
if (sessionId === this.activeSessionId) this._renderHistoryTruncationBanner();
}
/**
* Remember how much scrollback tmux holds for a session's pane, from any
* terminal response. Called whether or not the response was written, since
* it describes the PANE, not the payload. A response without the field (a
* byte-history fallback, an older server) forgets it, so the next tab switch
* goes back to the bounded tail: unknown never counts as empty.
*/
_notePaneHistory(sessionId, payload) {
if (!sessionId) return;
const lines = payload?.paneHistoryLines;
if (typeof lines === 'number' && Number.isFinite(lines) && lines >= 0) {
(this._paneHistoryLines ||= new Map()).set(sessionId, lines);
} else {
this._paneHistoryLines?.delete(sessionId);
}
}
/**
* Bring the partial-history notice up for `sessionId`, or retire it (null).
*
* The notice is LAZY: a tail replay is truncated on nearly every tab switch,
* and a bar over the top rows on every switch described history the user had
* not reached for. It now waits for the scroll gesture that reaches the top of
* the browser's buffer (the moment the missing part matters, and the same
* gesture that already re-pulls history), and goes away again once the user
* scrolls back down to live output. A tab switch retires it (selectSession).
*/
_setHistoryNoticeRevealed(sessionId) {
const next = sessionId || null;
if ((this._historyNoticeRevealedFor ?? null) === next) return;
this._historyNoticeRevealedFor = next;
this._renderHistoryTruncationBanner();
}
/**
* Paint the partial-history banner for the active session.
*
@@ -8755,22 +8896,13 @@ class CodemanApp {
* - recoverable → offer to load the rest
* - exhausted → say so plainly, offer nothing
* - at the limit → the full capture ITSELF hit the byte ceiling
*
* Shown only while revealed (`_setHistoryNoticeRevealed`: the user scrolled
* to the top of this tab's buffer) and never again for a session whose notice
* the user dismissed on this page.
*/
_renderHistoryTruncationBanner() {
const bar = document.getElementById('historyTruncationBar');
if (!bar) return;
const sessionId = this.activeSessionId;
const state = sessionId ? this._historyTruncation?.get(sessionId) : null;
const state = this.activeSessionId ? this._historyTruncation?.get(this.activeSessionId) : null;
const notice = computeHistoryTruncationNotice(state || {});
if (
!notice.visible ||
this._historyNoticeRevealedFor !== sessionId ||
this._historyNoticeDismissed?.has(sessionId)
) {
if (!notice.visible) {
bar.hidden = true;
return;
}
@@ -8803,9 +8935,6 @@ class CodemanApp {
dismiss.setAttribute('aria-label', 'Dismiss history notice');
dismiss.textContent = '×';
dismiss.onclick = () => {
// Sticky for this session until the page reloads: a dismissed notice used
// to come straight back on the next tab switch.
(this._historyNoticeDismissed ||= new Set()).add(sessionId);
bar.hidden = true;
};
bar.appendChild(dismiss);
@@ -8919,9 +9048,7 @@ class CodemanApp {
this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258). The switch lands at
// live output, so the notice waits for a scroll to the top again.
this._historyNoticeRevealedFor = null;
// session's notice stays on screen until then (#258).
this._renderHistoryTruncationBanner();
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
// Narrow SSE filter to the active session — server stops streaming
@@ -9192,23 +9319,7 @@ class CodemanApp {
// automatically replaying all of them makes tab selection scale with the
// entire session. Load its bounded 1MB tail first; the existing truncation
// banner action fetches ?full=1 when the user explicitly asks for it.
//
// A TUI pane whose last capture reported NO tmux scrollback (fullscreen
// claude: it lives in the alternate screen and keeps its transcript itself)
// takes `full=1` on every switch, not only the first. For such a pane that
// capture IS the visible frame, a few KB, while the tail is 1 MiB of the
// byte stream's old repaints. Measured on 11 live fullscreen claude panes:
// the tail took 250-1070 ms on the server and 70-510 ms to parse, and
// rendered one frame repeated (450 rows, 44 distinct); `full=1` took
// 150-330 ms and returned 0.9-5.6 KB. It also matches what a page load
// already shows, and an empty local buffer is what lets
// _maybePageCliTranscript send a wheel to the CLI's own transcript. The
// response re-reports the count, so a pane that starts keeping history
// (claude switched to its inline view) goes back to the tail on the next
// switch.
const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;
const useFullHistory =
session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
@@ -9238,7 +9349,6 @@ class CodemanApp {
}
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, data);
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
@@ -9958,8 +10068,10 @@ class CodemanApp {
try {
await this._apiDelete('/api/sessions');
// Every tiled session is gone: nothing left to remember or reselect.
this.closeTileGrid?.({ keepStored: false, reselect: false });
// Every tiled session is gone: nothing to reselect. The stored grid is
// kept like every other close; it now names only gone sessions, so the
// next Tiles click ranks the open sessions from scratch.
this.closeTileGrid?.({ keepStored: true, reselect: false });
this.sessions.clear();
this.terminalBuffers.clear();
this.terminalBufferCache.clear();
+295 -55
View File
@@ -1020,6 +1020,56 @@ function tabClusterNameSplit(name, label) {
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
}
/**
* Session-list search: the vertical rail's search box and the sidebar's filter
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
* Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or
* Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it.
* @param {unknown} query
* @returns {string} the needle, '' when there is nothing to search for
*/
function tabSearchNeedle(query) {
return typeof query === 'string' ? query.trim().toLowerCase() : '';
}
/**
* Which rows a search hides. Pure: the caller reads the rows off the list it
* rendered and applies the result as classes, so the list itself (grouping,
* order, Alt+N badges) is never rebuilt or reordered by a search.
*
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
* view filter). It counts toward its section, so its group stays on screen with
* it, but not toward `matchCount`.
*
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
* in list order; `section` is the row's group or case box, null/undefined for none.
* @param {unknown} query
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
* `counts` is the rows left showing per section (kept rows included), every
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
*/
function filterTabSearchRows(rows, query) {
const needle = tabSearchNeedle(query);
const hidden = new Set();
const counts = new Map();
let matchCount = 0;
for (const row of Array.isArray(rows) ? rows : []) {
const hasSection = row.section !== null && row.section !== undefined;
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
const text = typeof row.text === 'string' ? row.text.toLowerCase() : '';
const matches = !needle || text.includes(needle);
if (!matches && row.keep !== true) {
hidden.add(row.key);
continue;
}
if (matches) matchCount++;
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
}
return { active: needle.length > 0, hidden, counts, matchCount };
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -1301,6 +1351,63 @@ function cleanCopiedSelection(text, options) {
return lines.join('\n');
}
// ── Markdown heading anchors ────────────────────────────────────────────────
// marked emits no `id` on headings, so a rendered document's own `[Install](#installation)` links had
// nothing to jump to. And with `<base href="/">` a bare `#installation` href points at the dashboard's
// root, not at the page, so letting the browser follow it navigates the app away. The click delegate
// (`_bindResponseViewerInteractions`) therefore resolves in-document links itself, with the helpers
// below. Anchors are `data-md-anchor` attributes, NOT `id`s: a heading titled "Settings" must not claim
// the id of an element in the app's own DOM, and the lookup is scoped to the rendered document.
/**
* GitHub's heading slug: lower-cased, anything that is not a letter, mark, number, `_`, `-` or space
* dropped, each space a hyphen (`Why `codeman`? → `why-codeman`, `Über uns` → `über-uns`).
*/
function markdownHeadingSlug(text) {
return String(text ?? '')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N}_\- ]/gu, '')
.replace(/ /g, '-');
}
/** Give every h1..h6 under `root` its slug in `data-md-anchor`; a repeat gets `-1`, `-2`, ... as on GitHub. Idempotent. */
function assignMarkdownHeadingAnchors(root) {
const used = new Set();
for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const base = markdownHeadingSlug(heading.textContent);
let slug = base;
for (let n = 1; used.has(slug); n += 1) slug = `${base}-${n}`;
used.add(slug);
heading.dataset.mdAnchor = slug;
}
}
/**
* The element inside `root` that an in-document link (`#installation`, `#Installation`, `#my%20title`)
* points at, or null. An empty fragment (`#`) means the top of the document. Headings are matched by
* slug, then a heading the author wrote an explicit `<a id="...">`/`id` for, looked up INSIDE `root`
* only (never `document.getElementById`, which could find an app element of the same name).
*/
function findMarkdownAnchorTarget(root, href) {
let fragment = String(href ?? '').replace(/^#/, '');
try {
fragment = decodeURIComponent(fragment);
} catch {
/* a malformed escape: use it as written */
}
if (!fragment) return root;
assignMarkdownHeadingAnchors(root);
const wanted = [fragment.toLowerCase(), markdownHeadingSlug(fragment)];
for (const heading of root.querySelectorAll('[data-md-anchor]')) {
if (wanted.includes(heading.dataset.mdAnchor)) return heading;
}
for (const el of root.querySelectorAll('[id]')) {
if (el.id === fragment) return el;
}
return null;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -1358,6 +1465,10 @@ if (typeof window !== 'undefined') {
groupFor: tabTriageGroupFor,
layout: computeTabTriageLayout,
};
window.CodemanTabSearch = {
needle: tabSearchNeedle,
filter: filterTabSearchRows,
};
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
@@ -1370,6 +1481,11 @@ if (typeof window !== 'undefined') {
window.CodemanCopySelection = {
clean: cleanCopiedSelection,
};
window.CodemanMarkdownAnchors = {
slug: markdownHeadingSlug,
assign: assignMarkdownHeadingAnchors,
find: findMarkdownAnchorTarget,
};
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
@@ -1744,10 +1860,7 @@ function escapeHtml(text) {
function formatHistoryBytes(bytes) {
const n = typeof bytes === 'number' && isFinite(bytes) && bytes > 0 ? bytes : 0;
if (n < 1024) return 'less than 1 KB';
// Switch on the ROUNDED value: a 1 MiB tail cut back to a line boundary is
// just under 1 MiB and used to print as "1024 KB".
const kb = Math.round(n / 1024);
if (kb < 1024) return `${kb} KB`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -1761,27 +1874,12 @@ function formatHistoryBytes(bytes) {
* - atCeiling: the FULL capture itself hit the byte ceiling
* - exhausted: a full pull was refused as a downgrade, so this is all there is
*
* ⚠️ `truncated` measures the server's BYTE stream, not history a pull can
* return. `paneHistoryLines` (tmux `#{history_size}`) is the latter: 0 means the
* pane keeps no scrollback at all (a fullscreen CLI in the alternate screen,
* whose transcript lives in the CLI and is scrolled there), so the bytes a tail
* cut dropped are old repaint frames that `full=1` cannot bring back. Measured
* on live fullscreen claude panes: "4.8 MB more" was ~33 copies of one frame,
* and the button only ever ended in the downgrade refusal. Nothing to offer,
* nothing to say. Absent means unknown and keeps the byte-based behaviour.
*
* @param {{truncated?: boolean, reason?: string|null, source?: string|null,
* fullSize?: number, retainedBytes?: number, exhausted?: boolean,
* paneHistoryLines?: number|null}} state
* fullSize?: number, retainedBytes?: number, exhausted?: boolean}} state
* @returns {{visible: boolean, message: string, canLoadMore: boolean}}
*/
function computeHistoryTruncationNotice(state = {}) {
if (!state.truncated) return { visible: false, message: '', canLoadMore: false };
const historyLines =
typeof state.paneHistoryLines === 'number' && Number.isFinite(state.paneHistoryLines)
? Math.max(0, state.paneHistoryLines)
: null;
if (historyLines === 0) return { visible: false, message: '', canLoadMore: false };
const retained = Math.max(0, state.retainedBytes || 0);
const dropped = Math.max(0, (state.fullSize || 0) - retained);
@@ -1804,15 +1902,9 @@ function computeHistoryTruncationNotice(state = {}) {
canLoadMore: false,
};
}
// Name what a pull can actually return when the server said: the byte gap
// counts repaints and redraw bloat, and overstates it many times over.
const more =
historyLines !== null
? `${historyLines.toLocaleString('en-US')} ${historyLines === 1 ? 'line of scrollback is' : 'lines of scrollback are'} retained.`
: `${formatHistoryBytes(dropped)} more may still be retained.`;
return {
visible: true,
message: `Showing the most recent ${shown} of this session. ${more}`,
message: `Showing the most recent ${shown} of this session. ${formatHistoryBytes(dropped)} more may still be retained.`,
canLoadMore: true,
};
}
@@ -2110,11 +2202,11 @@ function tileGridCapacity({ width, height }) {
}
/**
* The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown,
* deleted, detached and duplicate ids are dropped, the list is capped at
* TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions
* must be 1 to 3 finite positive numbers. Anything that is not a v1 object
* (or its JSON) gives null.
* The stored grid (`codeman:tile-grid`: session ids and the layout, never
* content) made safe to apply: unknown, deleted, detached and duplicate ids
* are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must
* name a kept id, and track fractions must be 1 to 3 finite positive numbers.
* Anything that is not a v1 object (or its JSON) gives null.
*
* The stored `ids` are the grid's CELLS in reading order, `null` for an empty
* one (a hole can be any cell). The old packed list (no nulls) reads as cells
@@ -2122,11 +2214,20 @@ function tileGridCapacity({ width, height }) {
* every list consumer wants) and `cells` keeps the holes: a dropped id (gone,
* detached, a duplicate, past the cap) becomes `null` there, never a shift.
*
* `freed` names the cells whose session no longer exists (or was popped out
* to its own window) since the grid was stored: the ranking fills those first
* when the grid comes back (restoreTileGridCells). A hole the user left empty
* is not freed. `count` is how many tiles the grid had after the user's own
* last change (a session that went away by itself does not lower it), so the
* grid comes back to that many when there are sessions to fill it; a value
* stored before it existed, or a malformed one, reads as the number of
* sessions the stored cells name.
*
* @param {unknown} raw - the parsed value, or the stored JSON string
* @param {{has(id: string): boolean}|Iterable<string>} liveSessions - ids that exist now
* @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null,
* zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
* @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number,
* focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null}
*/
function sanitizeTileGridState(raw, liveSessions, detachedIds) {
let value = raw;
@@ -2137,11 +2238,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []);
const ids = [];
const cells = [];
const freed = [];
const named = [];
for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) {
const keep =
typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) &&
ids.length < TILE_GRID_MAX;
const isId = typeof id === 'string' && id !== '';
const present = isId && live.has(id) && !detachedIds?.has?.(id);
const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX;
if (keep) ids.push(id);
// Its session went away since: the cell is freed for the ranking to fill.
if (isId && !present && !named.includes(id)) freed.push(cells.length);
if (isId && !named.includes(id)) named.push(id);
// A malformed entry (not a string, not null) is a hole too.
cells.push(keep ? id : null);
}
@@ -2149,11 +2255,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null;
return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null;
};
const count =
Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX
? value.count
: Math.min(named.length, TILE_GRID_MAX);
return {
v: 1,
open: value.open === true && ids.length > 0,
ids,
cells,
freed,
count,
focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null),
zoomed: ids.includes(value.zoomed) ? value.zoomed : null,
colFr: fractions(value.colFr),
@@ -2161,6 +2273,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
};
}
/**
* A stored grid as it comes back (the Tiles button, a page reload): its cells
* exactly as stored, holes the user left included, and its focus (or the tile
* it had zoomed). Only when it holds fewer tiles than its `count` (sessions
* that went away since, or by themselves while it was open) does it fill, from
* `ranked` (best first, never a session already in it): the freed cells first,
* then the other empty cells, in reading order; more than the cells hold join
* after them (the shape grows when the grid lays them out, reformTileCells).
* A cell stays empty only when no other session is left to place. Never
* trimmed to the window: a grid larger than the window fits shows its focused
* tile alone until the window fits it again, and the arrangement stays.
*
* @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number,
* focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState)
* @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions)
* @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive
*/
function restoreTileGridCells(stored, ranked) {
const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : [];
const cells = [];
for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null);
const tiles = cells.filter(Boolean);
if (tiles.length === 0) return null;
const focusedId =
[stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0];
const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX);
const fillers = [];
for (const id of ranked || []) {
if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id);
}
const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []);
const empty = [];
cells.forEach((id, k) => {
if (id === null) empty.push(k);
});
// Freed cells first, each group in reading order.
empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b);
let count = tiles.length;
for (const k of empty) {
if (count >= target || fillers.length === 0) break;
cells[k] = fillers.shift();
count++;
}
const extra = fillers.slice(0, Math.max(0, target - count));
return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId };
}
/**
* New track fractions after a divider drag (grid-template `fr` values): the two
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
@@ -2193,7 +2352,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
/**
* The sessions the Tiles button can open (case c of tileGridOpenSet, and the
* ones a count fills a grid with), in tab order: live ones only, never a session popped out to
* ones a count fills a grid with), in the order given (tab order, or the
* ranking): live ones only, never a session popped out to
* its own window (that window owns its PTY size). A session with no PTY
* attached IS offered: its tile shows the Attach overlay.
*
@@ -2207,44 +2367,121 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
for (const id of sessionOrder) {
if (detachedIds?.has?.(id)) continue;
const session = sessions.get(id);
if (!session) continue;
if (!session || result.some((r) => r.id === id)) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
// Ranking groups (rankTileSessions): working first, then the sessions waiting
// on the user, then everything else.
const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 };
/**
* The stamp a session is ranked by inside its group. A WORKING session keys
* off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints
* about once a second, so its last-activity stamp is always "now", and one
* that never submitted would otherwise claim the head of the group. 0 means
* unknown. Every other state is the home screens' anchor (sessionActivityAnchor:
* the last byte the pane printed, i.e. when it went quiet).
*/
function tileRankStamp(row) {
if (row.state === 'working') return Number(row.lastSubmitAt) || 0;
return sessionActivityAnchor(row);
}
/**
* Which open sessions the tile grid shows when nobody said which (the Tiles
* button with no stored grid to bring back, and every place the grid fills a
* tile on its own: a count picked in its menu, a freed cell), best first
* (owner request: "prefer to load in tiles that are working and then the most
* recent, so the oldest don't get opened"):
* 1. WORKING, the most recently started turn first;
* 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a
* permission or question dialog; `waiting`: a finished turn not seen
* yet), most recent first;
* 3. then every other one (idle, done, error), most recently active first.
* Inside a group the newest stamp wins (tileRankStamp) and a session with no
* stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so
* the result never shuffles. The states are the home screens' own
* (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the
* order differs: the home screens put the longest-running turn first, the grid
* the most recent.
*
* Pure. Unit-tested in test/tile-grid-ranking.test.ts.
*
* @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows
* @returns {string[]} the ids, best first, each once
*/
function rankTileSessions(rows) {
const group = (row) => TILE_RANK_GROUP[row.state] ?? 2;
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id);
list.sort((a, b) => {
const byGroup = group(a) - group(b);
if (byGroup !== 0) return byGroup;
const atA = tileRankStamp(a);
const atB = tileRankStamp(b);
if (atA !== atB) {
if (!atA) return 1;
if (!atB) return -1;
return atB - atA;
}
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const ids = [];
for (const row of list) if (!ids.includes(row.id)) ids.push(row.id);
return ids;
}
/**
* What the Tiles button and Ctrl+Shift+G open, at once and without asking
* (owner decision 8). In order:
* a. the grid this tab last had (`stored`, already sanitized: live, not
* detached, at most the cap), if any of its sessions survive;
* detached, at most the cap), if any of its sessions survive, exactly
* as it was (restoreTileGridCells: its cells and holes, a cell its
* session freed filled from the ranking);
* b. else an open split's two sessions, Pane A focused;
* c. else the open sessions in tab order (buildTilePickerSessions: no
* detached ones), up to `limit`, the active session always among them and focused
* (when it sits past the limit, the first `limit - 1` others come with it).
* c. else the open sessions in `ranked` order (rankTileSessions: working,
* then needing input, then the most recent; tab order when no ranking is
* given), detached ones never, up to `limit`, the active session always
* among them and focused (when it ranks past the limit, the first
* `limit - 1` others come with it).
* Null when there is nothing to open.
*
* @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null,
* split?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
* @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number,
* focused: string|null, zoomed: string|null}|null,
* split?: string[]|null, ranked?: string[]|null, sessions: Map<string, object>, sessionOrder: string[],
* detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p
* @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null}
* @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[],
* focusedId: string|null}|null}
*/
function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) {
if (stored?.ids?.length) {
const focus = stored.zoomed || stored.focused;
return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] };
}
function tileGridOpenSet({
stored = null,
split = null,
ranked = null,
sessions,
sessionOrder,
detachedIds,
activeId = null,
limit,
}) {
const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map(
(c) => c.id
);
const restored = stored ? restoreTileGridCells(stored, all) : null;
if (restored) return { source: 'stored', ...restored };
const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id);
const pair = (split || []).filter(usable);
if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] };
const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX));
const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id);
if (all.length === 0) return null;
let ids = all.slice(0, max);
if (all.includes(activeId) && !ids.includes(activeId)) {
ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId];
}
return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] };
}
/**
@@ -2264,8 +2501,9 @@ function sanitizeTileCount(raw) {
* `base` (what the grid would open, or what an open grid shows, in its order)
* trimmed or filled to `n` tiles: trimmed from the end, the session to focus
* (`keepId`) always kept (it takes the last place when it sat past `n`, as in
* tileGridOpenSet's case c); filled from `all` (the open sessions in tab order)
* with the ones not in it yet. Fewer sessions than `n` give fewer tiles.
* tileGridOpenSet's case c); filled from `all` (the open sessions, best first:
* the app passes the ranking, rankTileSessions) with the ones not in it yet.
* Fewer sessions than `n` give fewer tiles.
*
* @param {string[]} base
* @param {string[]} all
@@ -2774,6 +3012,8 @@ if (typeof window !== 'undefined') {
fitTileCells,
cycleTile,
tileGridOpenSet,
restoreTileGridCells,
rankTileSessions,
sanitizeTileCount,
tileGridSetForCount,
tileCellCols,
+490 -43
View File
@@ -1,12 +1,13 @@
/**
* @fileoverview Entrance animations for the four things that appear when work
* @fileoverview Entrance animations for the things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
* that set all five to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Appearance → Entrance Animations.
* → Animations.
*
* Four constraints shape the design:
*
@@ -34,13 +35,28 @@
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
* before) and switched on in App Settings → Animations → Tile Animations, or
* preset by a theme. A styled tile plays in two beats that combine two
* surfaces. The FRAME enters as it mounts, in its own style
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
* pane). The load queue serves one capture at a time, so
* the screens light up one after another, the focused tile first. The frame
* styles move transform and opacity only (six tiles animate at once; a blur
* belongs to the serialized screen beat), and each has its own way out on the
* closing grid's still copy. A reload restores with `settle` whatever the
* setting.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
* replay, a window and a line, and replays the tile grid in place, so styles
* can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
* tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
@@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
@@ -102,30 +117,79 @@ const TERM_ANIM_STYLES = [
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/**
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
* still fits once at its final size (#464). `stagger` is the default gap
* between tiles and `order` the default cascade: `reading` (row by row),
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
* tile). `from`: the tile is measured against a source after layout (its tab,
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
*/
// prettier-ignore
const TILE_ANIM_STYLES = [
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
];
/** Cascade orders for the tile grid; `auto` is each style's own. */
const TILE_ANIM_ORDERS = [
{ key: 'auto', label: 'Style default' },
{ key: 'reading', label: 'Reading order' },
{ key: 'wave', label: 'Diagonal wave' },
{ key: 'ripple', label: 'Ripple from focus' },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
const TILE_EXIT_STAGGER_MS = 35;
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
];
/**
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
* when it is picked, but the tile style is its own setting (App Settings →
* Animations → Tile Animations, off by default), so changing it afterwards
* does not turn the theme into "Custom".
*/
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
* hook short-circuits on its first line. Opt in via App Settings → Animations,
* which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
const TILE_ANIM_DEFAULT = 'settle';
const TILE_ANIM_ORDER_DEFAULT = 'auto';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
tile: 'codeman:tileAnim',
tileOrder: 'codeman:tileAnimOrder',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
@@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
// were a surface gives them nothing new.
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
@@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, {
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
setTileAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
},
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
setTileAnimOrder(key, { persist = true } = {}) {
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
this._syncAnimLab?.();
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this.setTileAnimStyle(theme.tile);
this._syncEntranceAnimSetting?.();
},
/** The theme matching the four current styles, or 'custom' for a lab mix. */
/** The current style of each surface, keyed as ANIM_SURFACES. */
_currentAnimStyles() {
return {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
tile: this._tileAnimStyle,
};
},
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
const current = this._currentAnimStyles();
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
return match ? match.key : 'custom';
},
// ── App Settings picker ───────────────────────────────────────────────────
// ── App Settings → Animations ─────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
if (sel) {
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
}
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
// tracking, so this closes it as Cancel does (the row says so).
const labBtn = document.getElementById('appSettingsOpenAnimLab');
if (labBtn && !labBtn.dataset.bound) {
labBtn.dataset.bound = '1';
labBtn.addEventListener('click', () => {
this.closeAppSettings?.();
this.openAnimLab();
});
}
// Tile Animations: its own row, off (`settle`) by default. A theme picked
// above presets it; picked here, it applies to tiles alone.
const tileSel = document.getElementById('appSettingsTileAnim');
if (tileSel) {
tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
if (!tileSel.dataset.bound) {
tileSel.dataset.bound = '1';
tileSel.addEventListener('change', () => {
this.setTileAnimStyle(tileSel.value);
this._syncEntranceAnimSetting();
});
}
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
_tileAnimDuration() {
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
@@ -501,6 +618,317 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Tile grid ─────────────────────────────────────────────────────────────
/** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
tileEntranceStyle() {
return this._tileAnimStyle || TILE_ANIM_DEFAULT;
},
/**
* Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
* until the next frame, when its cell is final and _runTileEntrances can
* order it and measure it against its source. `setBackstop(ms)` arms the
* mount's own timer that ends the entrance should animationend never come
* (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
* so a frame that never comes cannot strand a tile invisible.
*/
_stageTileEntrance(el, sessionId, setBackstop) {
el.classList.add('tile--enter-themed', 'tile--enter-hold');
(this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
setBackstop(4000);
if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
},
/**
* One frame after the tiles mounted, every cell is final (openTileGrid packs,
* then a stored grid moves its tiles back). Each held tile gets its delay
* from the cascade order and, for `fly`/`deal`, the offset that starts it on
* its tab or the Tiles button (FLIP: transform only, so the fit it already
* did at its real size stands). `beam` draws its lines instead.
*/
_runTileEntrances() {
this._tileEnterRaf = 0;
const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
this._tileEnterQueue = [];
if (queue.length === 0) return;
const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
const speed = this._animSpeed || 1;
const stagger = def.stagger / speed;
const duration = this._tileAnimDuration();
const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
const ranks = this._tileEnterRanks(
queue.map((q) => q.sessionId),
order
);
const beams = [];
queue.forEach((item, k) => {
const { el, sessionId } = item;
const delay = ranks[k] * stagger;
el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
if (def.from) {
const to = el.getBoundingClientRect();
const from = this._tileSourceRect(def.from, sessionId);
if (from && to.width > 0 && to.height > 0) {
if (def.key === 'beam') beams.push({ from, to, delay });
else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
}
}
el.classList.remove('tile--enter-hold');
// When the frame lands, for a screen whose content arrives earlier.
el._tileEnterEndsAt = performance.now() + delay + hold + duration;
item.setBackstop(delay + hold + duration + 900);
});
if (beams.length > 0) this._drawTileBeams(beams);
},
/**
* Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
* row by row, `wave` by diagonal (row + column), `ripple` by distance from
* the focused tile. Equal keys share a step, so a diagonal lands together.
*/
_tileEnterRanks(ids, order) {
const grid = this._tileGrid;
const cols = Math.max(1, grid?.cols || 1);
const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
const pos = ids.map((id, k) => {
const c = cellOf(id);
return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
});
let keys;
if (order === 'wave') {
keys = pos.map((p) => p.row + p.col);
} else if (order === 'ripple') {
const f = cellOf(grid?.focusedId);
const fr = f < 0 ? 0 : Math.floor(f / cols);
const fc = f < 0 ? 0 : f % cols;
keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
} else {
keys = pos.map((p) => p.cell);
}
const steps = [...new Set(keys)].sort((a, b) => a - b);
return keys.map((v) => steps.indexOf(v));
},
/** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
_tileSourceRect(kind, sessionId) {
const visible = (node) => {
const r = node?.getBoundingClientRect?.();
if (!r || !(r.width > 0 && r.height > 0)) return null;
return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
};
if (kind === 'tab') {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
const r = visible(tab);
if (r) return r;
}
return visible(document.querySelector('.btn-tile-grid'));
},
/**
* The transform that puts a tile laid out at `to` onto `from`, as custom
* properties `<prefix>-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
* size for `fly` (it grows out of it), a small card turned a little for
* `deal`.
*/
_setTileFlight(el, from, to, kind, rank, prefix) {
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let sx;
let sy;
let rot = 0;
if (kind === 'deal') {
sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
rot = [-14, 10, -7, 13, -11, 8][rank % 6];
} else {
sx = clamp(from.width / to.width, 0.02, 1);
sy = clamp(from.height / to.height, 0.02, 1);
}
el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
},
/** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
_flashTileSourceTab(sessionId, delay) {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
if (!tab) return;
tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
tab.classList.remove('tab-launch');
void tab.offsetWidth;
tab.classList.add('tab-launch');
const onEnd = (e) => {
if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
};
const done = () => {
clearTimeout(timer);
tab.removeEventListener('animationend', onEnd);
tab.classList.remove('tab-launch');
tab.style.removeProperty('--tab-launch-delay');
};
const timer = setTimeout(done, delay + 900);
tab.addEventListener('animationend', onEnd);
},
/**
* `beam`: a line draws from each tile's tab (or the Tiles button) down into
* the middle of its tile, in the connection-line look, with a packet riding it
* when the line style is `packet`; the tile materializes as it lands. Its
* own overlay: the agent lines' one is rebuilt from scratch on every redraw.
* The overlay goes once every line has faded.
*/
_drawTileBeams(beams) {
const ns = 'http://www.w3.org/2000/svg';
let svg = document.getElementById('tileBeamLines');
if (!svg) {
svg = document.createElementNS(ns, 'svg');
svg.id = 'tileBeamLines';
svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
svg.setAttribute('aria-hidden', 'true');
document.body.appendChild(svg);
}
const packet = this._lineAnimStyle === 'packet';
let last = 0;
for (const { from, to, delay } of beams) {
const x1 = from.left + from.width / 2;
const y1 = from.bottom;
// Into the tile's middle: its top edge sits right under the tab strip,
// so a beam aimed there ran sideways along the strip instead of down.
const x2 = to.left + to.width / 2;
const y2 = to.top + to.height / 2;
const midY = (y1 + y2) / 2;
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line tile-beam-line');
svg.appendChild(path);
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
if (packet) {
const dot = path.cloneNode(false);
dot.setAttribute('class', 'connection-line-packet');
svg.appendChild(dot);
}
last = Math.max(last, delay);
}
clearTimeout(this._tileBeamTimer);
this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
},
/**
* A tile's screen lights up when its first capture lands (tile-grid.js load
* queue), in the terminal pane's style: the same keyframes as the main pane,
* on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
* tile at a time, since the queue serves one capture at a time), so the
* xterm inside keeps its size and its fit.
*/
playTileScreenEntrance(body) {
if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
body._codemanScreenDone?.();
// Content that lands while the frame is still flying in waits (held at
// its first keyframe, so hidden) until the frame has nearly landed: two
// beats, frame then screen, rather than both at once.
const frame = body.closest?.('.tile');
const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
body.style.setProperty('--tile-screen-delay', `${wait}ms`);
body.classList.remove('term-enter');
void body.offsetWidth;
body.classList.add('term-enter');
let timer = null;
const done = (e) => {
if (e && (e.target !== body || e.pseudoElement)) return;
clearTimeout(timer);
body.removeEventListener('animationend', done);
body.removeEventListener('animationcancel', done);
body.classList.remove('term-enter');
body.style.removeProperty('--tile-screen-delay');
if (body._codemanScreenDone === done) body._codemanScreenDone = null;
};
body._codemanScreenDone = done;
body.addEventListener('animationend', done);
body.addEventListener('animationcancel', done);
// Backstop, as the main pane's: a backgrounded tab never fires animationend.
timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
},
/**
* The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
* frame style's own way: `fly` back into each tab, `deal` gathered into the
* Tiles button, `crt` switched off to a dot, and so on. Copies leave in
* reading order, so the last one ends last (the layer goes on its
* animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
* covers tiles that stay. Returns how long the slowest copy takes, for the
* layer's fallback timer, or 0 for the default fade.
*/
_stageTileExit(copies, { now = false } = {}) {
const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
const ms = TILE_EXIT_MS[style];
if (now || !ms || copies.length === 0) return 0;
const speed = this._animSpeed || 1;
const def = TILE_ANIM_STYLES.find((s) => s.key === style);
copies.forEach(({ ghost, el, sessionId }, k) => {
ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
if (style !== 'fly' && style !== 'deal') return;
const at = el.getBoundingClientRect();
const target = this._tileSourceRect(def.from, sessionId);
if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
});
return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
},
/**
* Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
* light up again in queue order): no remount, reconnect or resize. With the
* grid closed it opens it, the real path.
*/
_demoTiles() {
const grid = this._tileGrid;
if (!grid?.open) {
if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
return;
}
if (!this._tileMotionAllowed?.()) return;
const ids = grid.ids.slice();
ids.forEach((id, k) => {
const entry = grid.tiles.get(id);
if (entry) this._replayTileEntrance?.(entry.el, id, k);
});
// The screens, as the load queue would land them: focused first, then reading order.
const speed = this._animSpeed || 1;
const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
clearTimeout(this._tileDemoTimer);
const timers = order.map((id, k) =>
setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
);
this._tileDemoTimers?.forEach(clearTimeout);
this._tileDemoTimers = timers;
},
/** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
_demoTilesRoundTrip() {
if (!this._tileGrid?.open) {
this._demoTiles();
return;
}
this.toggleTileGrid?.();
clearTimeout(this._tileDemoTimer);
this._tileDemoTimer = setTimeout(
() => {
if (!this._tileGrid?.open) this.toggleTileGrid?.();
},
1400 / (this._animSpeed || 1)
);
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
<label class="anim-lab-select">Tile order
<select data-select="tileOrder">
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
</select>
</label>
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
@@ -552,6 +986,11 @@ Object.assign(CodemanApp.prototype, {
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<div class="anim-lab-demo">
<span>Tiles</span>
<button type="button" data-demo="tiles">Replay</button>
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
@@ -570,11 +1009,17 @@ Object.assign(CodemanApp.prototype, {
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else if (attr === 'tile') this.setTileAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
this._syncEntranceAnimSetting?.();
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
});
});
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
this.setTileAnimOrder(e.target.value);
this.demoEntrance('tiles');
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
@@ -601,19 +1046,16 @@ Object.assign(CodemanApp.prototype, {
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
const current = this._currentAnimStyles();
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
});
const order = panel.querySelector('select[data-select="tileOrder"]');
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
@@ -647,10 +1089,15 @@ Object.assign(CodemanApp.prototype, {
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'} what */
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
if (what === 'tiles') return this._demoTiles();
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
// With the grid open, the "pane" is every tile's screen.
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
+2 -1
View File
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
line1.appendChild(badge);
} else {
// The agent's logo: PR #532's slot, the mode id as data (an id with no
// logo rule gets the slot's plain dot).
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
const logo = document.createElement('span');
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
logo.setAttribute('aria-hidden', 'true');
+43
View File
@@ -25,6 +25,7 @@
'.response-viewer-content',
'.file-preview-content',
'.session-tab-name',
'.tab-name',
'.session-name',
'.case-name',
'.notif-item-message',
@@ -61,6 +62,8 @@
'Collapse session sidebar': '收起会话侧边栏',
'Expand session sidebar': '展开会话侧边栏',
'Filter sessions': '筛选会话',
'Search sessions': '搜索会话',
'No sessions match': '没有匹配的会话',
'Admin Panel': '管理面板',
'Open admin panel': '打开管理面板',
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
@@ -340,6 +343,36 @@
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
English: 'English',
Appearance: '外观',
// App Settings > Animations (#571). 平铺 is the grid, 窗格 one tile in it.
Animations: '动画',
'How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.':
'标签页、终端面板、智能体窗口和平铺窗格如何出现。默认全部关闭,选择后立即生效。',
Entrances: '入场',
'Entrance Theme': '入场主题',
'One look for how new tabs, terminal panes, agent windows and their lines arrive.':
'为新标签页、终端面板、智能体窗口及其连线的出现方式选择统一的风格。',
'Off (default)': '关闭(默认)',
'Terminal (CRT)': '终端(CRT)',
'Beam down': '光束降临',
'Launch (tiles fly from tabs)': '发射(窗格从标签页飞出)',
'Soft focus (blur)': '柔焦(模糊)',
Quiet: '安静',
Playful: '活泼',
'Custom (set in the lab)': '自定义(在实验室中设置)',
'Tile Animations': '平铺动画',
'How tiles arrive when the grid opens and leave when it closes. A theme above presets it.':
'平铺打开时窗格如何出现、关闭时如何离开。上方的主题会预设此项。',
'Fly from tab': '从标签页飞出',
Deal: '发牌',
Cascade: '级联',
Pop: '弹出',
Soft: '柔和',
'None (tiles just appear)': '无(窗格直接出现)',
Lab: '实验室',
'Animation Lab': '动画实验室',
'Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.':
'关闭设置,并按界面并排打开所有样式,可重放和调速。等同于在网址后添加 ?animlab=1。',
'Open lab': '打开实验室',
Skin: '皮肤',
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
'Daylight Blue': '日光蓝',
@@ -369,6 +402,9 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
'CLI Logos on Tabs': '标签页上的 CLI 图标',
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
// 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.
@@ -592,6 +628,11 @@
'Audio Alerts': '声音提醒',
'Push Notifications': '推送通知',
'Notification Levels': '通知级别',
'Toast display time': '弹出提示显示时长',
'How long the corner pop-ups stay on screen.': '角落弹出提示在屏幕上停留的时长。',
'Browser notification display time': '浏览器通知显示时长',
'How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.':
'桌面通知在 Codeman 关闭它之前保持显示的时长。系统可能会更早关闭它。',
Critical: '严重',
'Per-Event Settings': '按事件设置',
'Permission prompts': '权限提示',
@@ -718,6 +759,8 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
+3 -1
View File
@@ -150,6 +150,7 @@ Object.assign(CodemanApp.prototype, {
const total = files.length;
let done = 0;
let failed = 0;
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
const results = new Array(total); // preserve selection order for insertion
const progress = () =>
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
@@ -173,6 +174,7 @@ Object.assign(CodemanApp.prototype, {
results[i] = await this._uploadPasteImage(sessionId, normalized);
} catch (err) {
failed++;
if (!failReason && err && err.message) failReason = err.message;
console.warn('Image upload failed:', err);
results[i] = null;
} finally {
@@ -196,7 +198,7 @@ Object.assign(CodemanApp.prototype, {
// Final status: successes, plus any failures / cap so nothing is silent.
const parts = [];
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
if (failed > 0) parts.push(`${failed} failed`);
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
+95 -20
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -394,6 +394,19 @@
<!-- Main Terminal Area -->
<main class="main">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
<!-- Session-name search (app.js setTabRailSearch): a view filter over the
rows below, in memory only. Shown with the rail, never elsewhere. -->
<div class="session-sidebar-filter tab-rail-search">
<input type="search" id="tabRailSearch" class="session-sidebar-filter-input"
placeholder="Search sessions" aria-label="Search sessions"
autocomplete="off" spellcheck="false"
oninput="app.setTabRailSearch(this.value)"
onkeydown="app.handleTabRailSearchKeydown(event)">
<button type="button" id="tabRailSearchClear" class="tab-rail-search-clear"
aria-label="Clear search" title="Clear search"
onclick="app.clearTabRailSearch()" hidden>&times;</button>
</div>
<div id="tabRailSearchEmpty" class="tab-rail-search-empty" role="status" hidden>No sessions match</div>
<div
id="tabRailResizeHandle"
class="tab-rail-resize-handle"
@@ -1635,6 +1648,7 @@
Save; row-reverse keeps Save to the left of it on phones. -->
<div class="set-head-actions">
<button class="modal-close" onclick="app.closeAppSettings()" aria-label="Close app settings">&times;</button>
<button class="set-head-save set-head-apply" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
<button class="set-head-save" onclick="app.saveAppSettings()">Save</button>
</div>
</div>
@@ -1663,6 +1677,10 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<span>Appearance</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-animations">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<span>Animations</span>
</button>
<button type="button" class="set-rail-item" data-section="settings-models">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
<span>Models</span>
@@ -2053,7 +2071,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
<h2>Appearance</h2>
</div>
<p class="set-section-blurb">Theme, motion, and what this install calls itself.</p>
<p class="set-section-blurb">Theme, and what this install calls itself.</p>
<div class="set-group">
<div class="set-group-head"><h4>Theme</h4><span class="set-scope">device</span></div>
@@ -2077,21 +2095,6 @@
</optgroup>
</select>
</div>
<div class="set-row has-field" data-search="entrance animations motion tabs windows">
<div class="set-row-text">
<span class="set-row-label">Entrance Animations</span>
<span class="set-row-desc">How new tabs, panes and agent windows arrive. Add ?animlab=1 to the URL for per-surface control.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="softfocus">Soft focus (blur)</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
</div>
</div>
@@ -2216,6 +2219,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
<div class="set-row-text">
<span class="set-row-label">CLI Logos on Tabs</span>
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
</div>
<div class="set-row" data-search="pop out detach tab window">
<div class="set-row-text">
<span class="set-row-label">Pop-out Button on Tabs</span>
@@ -2248,6 +2258,70 @@
</div>
</section>
<!-- ══ Animations ═══════════════════════════════════════════════
Every entrance animation in one place (entrance-animations.js).
All per-device localStorage keys, applied as they are picked,
never part of PUT /api/settings. -->
<section class="set-section" id="settings-animations" data-label="Animations">
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
<h2>Animations</h2>
</div>
<p class="set-section-blurb">How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.</p>
<div class="set-group">
<div class="set-group-head"><h4>Entrances</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="entrance animations theme motion tabs windows panes lines">
<div class="set-row-text">
<span class="set-row-label">Entrance Theme</span>
<span class="set-row-desc">One look for how new tabs, terminal panes, agent windows and their lines arrive.</span>
</div>
<select id="appSettingsEntranceAnim" class="set-select">
<option value="legacy">Off (default)</option>
<option value="terminal">Terminal (CRT)</option>
<option value="beamdown">Beam down</option>
<option value="launch">Launch (tiles fly from tabs)</option>
<option value="softfocus">Soft focus (blur)</option>
<option value="quiet">Quiet</option>
<option value="playful">Playful</option>
<option value="custom">Custom (set in the lab)</option>
</select>
</div>
<div class="set-row has-field" data-search="tile animations tiles grid motion entrance fly deal crt beam">
<div class="set-row-text">
<span class="set-row-label">Tile Animations</span>
<span class="set-row-desc">How tiles arrive when the grid opens and leave when it closes. A theme above presets it.</span>
</div>
<select id="appSettingsTileAnim" class="set-select">
<option value="settle">Off (default)</option>
<option value="fly">Fly from tab</option>
<option value="deal">Deal</option>
<option value="crt">CRT</option>
<option value="beam">Beam down</option>
<option value="cascade">Cascade</option>
<option value="pop">Pop</option>
<option value="soft">Soft</option>
<option value="off">None (tiles just appear)</option>
</select>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Lab</h4></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="animation lab compare replay styles per surface animlab">
<div class="set-row-text">
<span class="set-row-label">Animation Lab</span>
<span class="set-row-desc">Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.</span>
</div>
<button type="button" id="appSettingsOpenAnimLab" class="btn-toolbar btn-sm">Open lab</button>
</div>
</div>
</div>
</section>
<!-- ══ Models ═══════════════════════════════════════════════════ -->
<section class="set-section" id="settings-models" data-label="Models">
<div class="set-section-head">
@@ -2644,17 +2718,17 @@
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity copilot github">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs, and GitHub Copilot CLI if it is installed, by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers (and GitHub Copilot CLI's) into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>, <code>COPILOT_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
@@ -3057,6 +3131,7 @@
</div>
<div class="form-actions set-foot">
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
<button class="btn-toolbar" onclick="app.applyAppSettings()" title="Save and keep Settings open">Apply</button>
<button class="btn-toolbar btn-primary" onclick="app.saveAppSettings()">Save</button>
</div>
</div>
+1 -1
View File
@@ -3552,7 +3552,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
}
/* Save + Close are the two ways out of the sheet (save-and-close vs
discard-and-close), hit in the same corner with the same thumb, so here —
discard-and-close; Apply saves but keeps the sheet open), hit in the same corner with the same thumb, so here —
and only here, since Save is header-only below 860px — they share a
recessed tray and matching pill geometry instead of reading as a fat
accent pill parked beside a stray × glyph. Tray colors come from skin
+1 -1
View File
@@ -483,7 +483,7 @@ class NotificationManager {
notif.read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
}
}
// Switch to session if available
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
+93 -8
View File
@@ -487,6 +487,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
@@ -1226,15 +1227,15 @@ Object.assign(CodemanApp.prototype, {
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
const show = (html, hint = '') => {
if (out) { out.style.display = 'block'; out.innerHTML = html; out.dataset.hint = hint; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
show('Apply or Save settings to turn MCP sync on first, then preview or sync.', 'save-first');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file (and GitHub Copilot CLI\'s, when it is installed)? Env values and headers on those servers are copied too.')) return;
show('Working…');
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
let body = null;
@@ -2512,7 +2513,31 @@ Object.assign(CodemanApp.prototype, {
claudeEl.className = 'voice-provider-status' + (status?.available ? ' active' : '');
},
/**
* Apply button: the same save as Save, but the modal stays open and the MCP sync group (its
* Preview and Sync need the saved flag) and the CLI management writes are refreshed in place, so
* turning either on needs no close-and-reopen. It is a wrapper rather than an option on
* saveAppSettings() so that function's signature (which tests locate by text) stays as it was.
*
* `_keepSettingsOpenOnce` is the one-shot intent and `_applyInFlight` the double-click guard:
* saveAppSettings() consumes the intent before its first await, so a Save clicked while an Apply
* is still in flight is an ordinary Save and closes the modal.
*/
async applyAppSettings() {
if (this._applyInFlight) return;
this._applyInFlight = true;
this._keepSettingsOpenOnce = true;
try {
await this.saveAppSettings();
} finally {
this._applyInFlight = false;
this._keepSettingsOpenOnce = false;
}
},
async saveAppSettings() {
const keepOpen = this._keepSettingsOpenOnce === true;
this._keepSettingsOpenOnce = false;
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
@@ -2583,6 +2608,7 @@ Object.assign(CodemanApp.prototype, {
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
@@ -2857,6 +2883,7 @@ Object.assign(CodemanApp.prototype, {
...serverSettings
} = settings;
let webhookError = '';
let serverSaved = false;
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2874,10 +2901,14 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
this.closeAppSettings();
if (!keepOpen) this.closeAppSettings();
return;
}
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// Save model configuration separately
await this.saveModelConfigFromSettings();
@@ -2889,7 +2920,7 @@ Object.assign(CodemanApp.prototype, {
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else {
this.showToast('Settings saved', 'success');
this.showToast(keepOpen ? 'Settings applied' : 'Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
@@ -2901,9 +2932,13 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
// Only when the settings PUT landed: after a 400 or a dropped connection the server still has the
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else {
} else if (!keepOpen) {
this.closeAppSettings();
}
@@ -2924,6 +2959,25 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* After Apply: bring the groups whose contents depend on a SAVED value up to date without
* reopening the modal. openAppSettings does the same on open; this is the part of it that
* a save can change, without touching what the user is editing or the scroll position.
*/
_refreshSettingsAfterApply(settings) {
// The MCP routes read the saved flag, so switching it on is only usable from now.
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
const out = this.$('mcpSyncResult');
if (this._mcpSyncSavedOn && out && out.dataset?.hint === 'save-first') {
out.style.display = 'none';
out.innerHTML = '';
}
this.applyMcpSyncVisibility();
this.applyCustomModelEndpointsVisibility();
this.applyCliManagementVisibility();
this._applyDoctorAdminGate();
},
// Load model configuration from server for the settings modal
async loadModelConfigForSettings() {
try {
@@ -3541,6 +3595,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
showTabCliLogos: true,
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
@@ -3677,6 +3732,16 @@ Object.assign(CodemanApp.prototype, {
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
},
/**
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
* device). Anything but an explicit false reads as on, the same test the
* pre-paint script in index.html applies, so a reload and a Save never
* disagree about an odd stored value.
*/
tabCliLogosEnabled(settings) {
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
},
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
resolveTabStateOrder(settings) {
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
@@ -3932,6 +3997,10 @@ Object.assign(CodemanApp.prototype, {
})
: 'horizontal';
// The search box lives in the rail: a search left applied after the list
// moves out would hide tabs with no box to clear it from.
if (orientation !== 'vertical' && this._tabRailSearch) this._resetTabRailSearch?.();
const root = document.documentElement;
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
@@ -3964,6 +4033,14 @@ Object.assign(CodemanApp.prototype, {
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
const stateOrder = this.resolveTabStateOrder(settings);
root.dataset.tabStateOrder = stateOrder;
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
// of `changed` below: the logo spans are always in the markup. It still
// resizes every agent tab, which the tail of this function settles.
const previousLogos = root.dataset.tabLogos;
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
root.dataset.tabLogos = logos;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
@@ -4013,6 +4090,14 @@ Object.assign(CodemanApp.prototype, {
if (!wrapRendered) this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
} else if (previousLogos !== logos) {
// A logo flip narrows or widens every agent tab with no render behind
// it, so re-take what a render would have: the strip's one-row wrap
// decision and the lines anchored to tab rects (lineage, subagent
// connectors). A header that gains or loses a row resizes the terminal
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
this.updateTabOverflowMode?.();
this._updateConnectionLinesImmediate?.();
}
// Only detailed rows carry stamps that go stale with no event behind them.
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
@@ -4261,7 +4346,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
+681 -53
View File
@@ -1270,6 +1270,26 @@ html[data-tab-anim="blur"] .session-tab.tab-enter {
}
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
.anim-lab-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -2px 0 10px;
padding: 0 2px;
color: var(--text-dim);
font-size: 0.66rem;
}
.anim-lab-select select {
background: var(--bg-dark, #111);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.66rem;
padding: 2px 4px;
}
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
.anim-lab-demo {
@@ -1456,11 +1476,20 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
frame. `will-change` is deliberately not set, the base rule needs its
`will-change: contents` for terminal compositing. */
.terminal-container.term-enter {
/* A tile's screen (.tile-body, tile-grid.js) plays the same style when its
first capture lands: the same rules, so a tile combines its frame's own
entrance with the pane style. Its xterm's opacity fade stands aside for it. */
.terminal-container.term-enter,
.tile-body.term-enter {
animation-fill-mode: both;
}
.terminal-container.term-enter::before {
.tile-body.term-enter .xterm {
transition: none;
}
.terminal-container.term-enter::before,
.tile-body.term-enter::before {
content: "";
position: absolute;
inset: 0;
@@ -1470,14 +1499,29 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
animation-fill-mode: both;
}
/* A screen whose content lands while its frame is still entering waits for
it (--tile-screen-delay, entrance-animations.js): the body holds its first
keyframe meanwhile (hidden), its wash does not (a held wash is a bright
static block). */
.tile-body.term-enter {
animation-delay: var(--tile-screen-delay, 0ms);
}
.tile-body.term-enter::before {
animation-delay: var(--tile-screen-delay, 0ms);
animation-fill-mode: forwards;
}
/* CRT, power-on: a hot line that expands to full height. */
html[data-term-anim="crt"] .terminal-container.term-enter {
html[data-term-anim="crt"] .terminal-container.term-enter,
html[data-term-anim="crt"] .tile-body.term-enter {
animation-name: term-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-term-anim="crt"] .terminal-container.term-enter::before {
html[data-term-anim="crt"] .terminal-container.term-enter::before,
html[data-term-anim="crt"] .tile-body.term-enter::before {
animation-name: term-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1498,55 +1542,16 @@ html[data-term-anim="crt"] .terminal-container.term-enter::before {
100% { opacity: 0; background: transparent; }
}
/* Boot, flickers on under a green scan sweep. */
html[data-term-anim="boot"] .terminal-container.term-enter {
animation-name: term-enter-boot;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-term-anim="boot"] .terminal-container.term-enter::before {
background: linear-gradient(
180deg,
transparent 0%,
rgba(0, 255, 102, 0.05) 40%,
rgba(200, 255, 220, 0.28) 50%,
rgba(0, 255, 102, 0.05) 60%,
transparent 100%
);
background-size: 100% 300%;
animation-name: term-enter-boot-scan;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes term-enter-boot {
0% { opacity: 0; transform: scale(0.995); }
8% { opacity: 0.85; }
15% { opacity: 0.1; }
24% { opacity: 1; }
33% { opacity: 0.35; }
45% { opacity: 1; transform: none; }
58% { opacity: 0.7; }
70% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-boot-scan {
0% { opacity: 0; background-position: 0 -150%; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: 0 150%; }
}
/* Wipe, reveals top-to-bottom behind a bright edge. */
html[data-term-anim="wipe"] .terminal-container.term-enter {
html[data-term-anim="wipe"] .terminal-container.term-enter,
html[data-term-anim="wipe"] .tile-body.term-enter {
animation-name: term-enter-wipe;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
html[data-term-anim="wipe"] .terminal-container.term-enter::before,
html[data-term-anim="wipe"] .tile-body.term-enter::before {
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
animation-name: term-enter-wipe-edge;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
@@ -1565,7 +1570,8 @@ html[data-term-anim="wipe"] .terminal-container.term-enter::before {
}
/* Slide up, rises into place from below. */
html[data-term-anim="slide"] .terminal-container.term-enter {
html[data-term-anim="slide"] .terminal-container.term-enter,
html[data-term-anim="slide"] .tile-body.term-enter {
animation-name: term-enter-slide;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
@@ -1577,7 +1583,8 @@ html[data-term-anim="slide"] .terminal-container.term-enter {
}
/* Fade, quiet, with a touch of scale. */
html[data-term-anim="fade"] .terminal-container.term-enter {
html[data-term-anim="fade"] .terminal-container.term-enter,
html[data-term-anim="fade"] .tile-body.term-enter {
animation-name: term-enter-fade;
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1615,7 +1622,8 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
before, during and after a run), and the property allowlist for every one of
these keyframes is pinned by test/entrance-animations.test.ts. */
html[data-term-anim="blur"] .terminal-container.term-enter {
html[data-term-anim="blur"] .terminal-container.term-enter,
html[data-term-anim="blur"] .tile-body.term-enter {
animation-name: term-enter-blur;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
@@ -1738,10 +1746,20 @@ html[data-line-anim="blur"] .connection-line.line-enter {
.ultracode-window.win-enter::before,
.terminal-container.term-enter,
.terminal-container.term-enter::before,
.tile-body.term-enter,
.tile-body.term-enter::before,
.tile.tile--entering,
.tile.tile--entering::before,
.connection-line.line-enter,
.connection-line-packet {
.connection-line-packet,
.connection-line.tile-beam-line,
.session-tab.tab-launch::after {
animation: none !important;
}
.tile-beam-lines {
display: none;
}
}
.session-tab .tab-status {
@@ -3010,6 +3028,19 @@ body.solo-mode .btn-lifecycle-log {
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
colour lives here any more. Only the shell keeps a pill. */
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
applyTabOrientation() and the pre-paint script in index.html stamp the
attribute). The logo leaves the session tabs (header strip, vertical rail,
sidebar, grouped rail, phone chips) and the desktop home rail; the status
dot and the shell's SH pill stay. Every row it sits in spaces its children
with flex `gap`, which skips a display:none item, so no empty slot is left.
Tile and split headers (.tile-harness, .split-harness) and the Run menus
keep their logos: only these two classes are named here. */
html[data-tab-logos='off'] .session-tab .tab-harness,
html[data-tab-logos='off'] .home-sessions-harness {
display: none;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -16974,6 +17005,12 @@ html[data-tab-orientation='vertical'] .home-sessions {
transform 0.1s ease;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save.set-head-apply {
background: transparent;
color: var(--text);
box-shadow: inset 0 0 0 1px var(--border);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:hover {
filter: brightness(1.08);
}
@@ -18777,11 +18814,83 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-right {
Scoped to the sidebar layout on purpose: applySidebarFilter() already strips
the class whenever the filter box is off screen, and this prefix is the
second lock — a leaked class must never be able to hide tabs from the header
strip, which has no filter control to clear it with. */
html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
strip, which has no filter control to clear it with. The shared filter
(_applyTabListFilter) also marks a case box (tabArrangement 'case') the
filter emptied, which hides with its label and count rather than staying
on screen reading 0. */
html[data-session-list="sidebar"] .session-tab.tab-filtered-out,
html[data-session-list="sidebar"] .tab-cluster.tab-filtered-out {
display: none !important;
}
/* The vertical rail's session search (app.js _applyTabListFilter) uses the same
class, and also hides a group or case box the search emptied. Rail-scoped for
the same reason as the sidebar rule above: the header strip has no box. */
html[data-tab-orientation='vertical'] .tab-rail .session-tab.tab-filtered-out,
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group.tab-filtered-out,
html[data-tab-orientation='vertical'] .tab-rail .tab-cluster.tab-filtered-out {
display: none !important;
}
/* The box reuses .session-sidebar-filter / -input; this only adds the clear
button over its right edge and the "No sessions match" line. */
.tab-rail-search {
position: relative;
/* Clear of the 16px resize handle on the rail's right edge. */
padding-right: calc(0.25rem + 16px);
}
.tab-rail-search .session-sidebar-filter-input {
padding-right: 1.6rem;
}
/* The native WebKit clear glyph would sit under ours. */
.tab-rail-search .session-sidebar-filter-input::-webkit-search-cancel-button {
appearance: none;
}
.tab-rail-search-clear {
position: absolute;
top: 50%;
right: calc(0.45rem + 16px);
width: 1.2rem;
height: 1.2rem;
padding: 0;
transform: translateY(-50%);
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-dim);
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
}
.tab-rail-search-clear:hover,
.tab-rail-search-clear:focus-visible {
background: var(--control-bg-hover);
color: var(--text);
}
.tab-rail-search-clear[hidden],
.tab-rail-search-empty[hidden] {
display: none;
}
.tab-rail-search-empty {
flex-shrink: 0;
padding: 0.5rem 0.75rem;
color: var(--text-dim);
font-size: 0.75rem;
text-align: center;
}
/* Every group is drawn open while searching and its header will not toggle,
so the chevron dims to say so. */
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-filtering .tab-layout-group-chevron {
opacity: 0.35;
}
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
/* The detailed variant of the SAME sidebar: identical column, identical
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
@@ -20062,6 +20171,314 @@ body.tile-grid-resizing--row * {
}
}
/* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ───
Picked in App Settings → Animations (Tile Animations, or preset by the
Entrance Theme) or per surface in the lab (?animlab=1); `settle` is the rule above, the default,
and what a reload restores with. Any other style is held one frame
(.tile--enter-hold: invisible, not animating) while the entrance module
orders the cascade and measures the tile's source on the final layout, then
arrives with .tile--enter-themed and an inline --tile-enter-delay (and, for
`fly` and `deal`, the --tile-from-* offset onto the tab or the Tiles button).
FRAME keyframes animate transform and opacity only: FitAddon reads the
untransformed layout box (#464), and six tiles run at once, so no filter
(a blur belongs to the screen beat, one tile at a time). Colour goes on a
::before wash. Each style also has its own way out on the closing grid's
still copy (tile-leave-*, below). */
html[data-tile-anim="settle"] .tile.tile--entering {
animation-name: tile-enter;
}
.tile.tile--entering.tile--enter-themed {
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity;
}
.tile.tile--entering.tile--enter-themed::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
}
.tile.tile--enter-hold {
opacity: 0;
}
.tile.tile--enter-hold,
.tile.tile--enter-hold::before {
animation: none !important;
}
/* Fly from tab: grows out of its session tab (FLIP onto the tab's own box),
overshoots a hair and lands. */
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-fly;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.2, 0.9, 0.25, 1);
}
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed::before {
animation-name: tile-wash-launch;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-fly {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -48px))
scale(var(--tile-from-sx, 0.3), var(--tile-from-sy, 0.1));
}
16% {
opacity: 1;
}
74% {
transform: translate(0, 0) scale(1.01);
}
100% {
opacity: 1;
transform: none;
}
}
@keyframes tile-wash-launch {
0% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent);
box-shadow: inset 0 0 0 2px var(--accent, #4a9eff);
}
55% {
opacity: 0.55;
background: color-mix(in srgb, var(--accent, #4a9eff) 12%, transparent);
}
100% {
opacity: 0;
background: transparent;
box-shadow: inset 0 0 0 1px transparent;
}
}
/* Deal: dealt out of the Tiles button like cards, each turned a little, then
squared up in its cell. */
html[data-tile-anim="deal"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-deal;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 0.85, 0.3, 1);
}
@keyframes tile-enter-deal {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -240px)) scale(var(--tile-from-sx, 0.1))
rotate(var(--tile-from-rot, -10deg));
}
10% {
opacity: 1;
}
70% {
transform: translate(0, 0) scale(1.02) rotate(calc(var(--tile-from-rot, -10deg) * -0.1));
}
88% {
transform: scale(0.996) rotate(0deg);
}
100% {
opacity: 1;
transform: none;
}
}
/* CRT: powers on as a hot line, then unfolds, under the agent windows' flash. */
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-crt {
0% {
opacity: 0;
transform: scale3d(0.5, 0.006, 1);
}
22% {
opacity: 1;
transform: scale3d(1, 0.01, 1);
}
58% {
opacity: 1;
transform: scale3d(1, 1.04, 1);
}
80% {
opacity: 1;
transform: scale3d(1, 0.985, 1);
}
100% {
opacity: 1;
transform: none;
}
}
/* Beam down: holds still while a beam draws down from its tab (the entrance
module's own overlay, .tile-beam-line), then materializes out of the agent
windows' green wash. No transform, so the beam lands where the tile is. */
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-beam;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-beam-wash;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-beam {
0% {
opacity: 0;
}
20% {
opacity: 0.55;
}
32% {
opacity: 0.2;
}
48% {
opacity: 0.9;
}
100% {
opacity: 1;
}
}
/* Cascade: swings down from its top edge, a diagonal at a time. */
html[data-tile-anim="cascade"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-cascade;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes tile-enter-cascade {
0% {
opacity: 0;
transform: perspective(1400px) rotateX(-80deg);
}
55% {
opacity: 1;
transform: perspective(1400px) rotateX(8deg);
}
80% {
transform: perspective(1400px) rotateX(-2.5deg);
}
100% {
opacity: 1;
transform: perspective(1400px) rotateX(0deg);
}
}
/* Pop: springs open from its centre, rippling out from the focused tile. */
html[data-tile-anim="pop"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-pop;
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-pop {
0% {
opacity: 0;
transform: scale(0.6);
}
58% {
opacity: 1;
transform: scale(1.03);
}
80% {
transform: scale(0.993);
}
100% {
opacity: 1;
transform: none;
}
}
/* Soft: a slow drift up into place; with the Soft focus theme each screen
then focus-pulls in (the pane's `blur` style on .tile-body). */
html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-soft;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tile-enter-soft {
0% {
opacity: 0;
transform: translateY(16px) scale(0.94);
}
100% {
opacity: 1;
transform: none;
}
}
/* The tab a tile flies (or beams) out of glows as it goes. */
.session-tab.tab-launch:not(.tab-loading)::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
animation: tab-launch-flash calc(520ms * var(--anim-enter-scale, 1)) ease-out var(--tab-launch-delay, 0ms) both;
}
@keyframes tab-launch-flash {
0% {
opacity: 0;
background: color-mix(in srgb, var(--accent, #4a9eff) 40%, transparent);
box-shadow: 0 0 0 1px var(--accent, #4a9eff), 0 0 14px 2px color-mix(in srgb, var(--accent, #4a9eff) 70%, transparent);
}
18% {
opacity: 1;
}
100% {
opacity: 0;
background: transparent;
box-shadow: 0 0 0 1px transparent, 0 0 0 0 transparent;
}
}
/* `beam`'s lines: the connection-line look, drawn from the tab down to the
tile, held while the tile materializes, then faded. Their own overlay
(#tileBeamLines), removed by the entrance module once they are done. */
.connection-line.tile-beam-line {
stroke-dasharray: var(--line-len);
animation:
line-enter-draw calc(380ms * var(--anim-enter-scale, 1)) cubic-bezier(0.32, 0.8, 0.3, 1)
var(--line-enter-delay, 0ms) both,
tile-beam-out calc(420ms * var(--anim-enter-scale, 1)) ease-in
calc(var(--line-enter-delay, 0ms) + 640ms * var(--anim-enter-scale, 1)) forwards;
}
@keyframes tile-beam-out {
from {
opacity: 0.9;
}
to {
opacity: 0;
}
}
.tile-body .xterm {
transition: opacity 160ms ease-out;
}
@@ -20136,9 +20553,220 @@ body.tile-grid-resizing--row * {
}
}
/* The entrance style's own way out (entrance-animations.js _stageTileExit),
on the still copy only and only for the Tiles toggle's close: a re-form's
copy (--now) covers tiles that stay, so it keeps the plain fade above.
Copies leave in reading order (--tile-exit-delay), so the last one ends
last and takes the layer with it. */
.tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-fly calc(460ms * var(--anim-enter-scale, 1)) cubic-bezier(0.55, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-return calc(460ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-fly {
0% {
opacity: 0.8;
transform: scale(0.985);
}
75% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -48px))
scale(var(--tile-to-sx, 0.3), var(--tile-to-sy, 0.1));
}
}
@keyframes tile-wash-return {
0% {
opacity: 0;
background: transparent;
}
100% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 55%, transparent);
}
}
html[data-tile-anim="deal"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-deal calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.25)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-deal {
0% {
opacity: 0.8;
transform: scale(0.985);
}
22% {
transform: scale(1.02) rotate(calc(var(--tile-to-rot, -10deg) * -0.15));
}
85% {
opacity: 0.85;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -240px)) scale(var(--tile-to-sx, 0.1))
rotate(var(--tile-to-rot, -10deg));
}
}
/* CRT: the classic switch-off, down to a line, then a dot, then dark. */
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-crt calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.6, 1)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-crt-off calc(520ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-crt {
0% {
opacity: 0.8;
transform: scale(0.985);
}
42% {
opacity: 1;
transform: scale3d(1, 0.012, 1);
}
72% {
opacity: 1;
transform: scale3d(0.03, 0.012, 1);
}
100% {
opacity: 0;
transform: scale3d(0, 0, 1);
}
}
@keyframes tile-wash-crt-off {
0% {
opacity: 0;
background: transparent;
}
38% {
opacity: 1;
background: rgba(220, 255, 235, 0.95);
}
100% {
opacity: 1;
background: rgba(255, 255, 255, 1);
}
}
/* Beam down: beamed back up, flickering out under a rising wash. */
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-beam calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-beam-up calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-beam {
0% {
opacity: 0.8;
}
40% {
opacity: 0.9;
}
55% {
opacity: 0.3;
}
70% {
opacity: 0.6;
}
100% {
opacity: 0;
}
}
@keyframes tile-wash-beam-up {
0% {
opacity: 0;
background: linear-gradient(0deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0));
}
100% {
opacity: 1;
background: linear-gradient(0deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0.2));
}
}
/* Cascade: folds back up on its top edge. */
html[data-tile-anim="cascade"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-cascade calc(480ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
transform-origin: 50% 0%;
}
@keyframes tile-leave-cascade {
0% {
opacity: 0.8;
transform: perspective(1400px) rotateX(0deg) scale(0.985);
}
100% {
opacity: 0;
transform: perspective(1400px) rotateX(82deg) scale(0.985);
}
}
/* Pop: a last swell, then it pops away. */
html[data-tile-anim="pop"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-pop calc(380ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-pop {
0% {
opacity: 0.8;
transform: scale(0.985);
}
35% {
opacity: 0.9;
transform: scale(1.03);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* Soft: sinks away slowly. */
html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-soft calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.7, 0.4)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-soft {
0% {
opacity: 0.8;
transform: scale(0.985);
}
100% {
opacity: 0;
transform: translateY(14px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.tile-count-menu,
.tile.tile--entering,
.tile.tile--entering::before,
.tile.tile--needs::after,
.tile.tile--loading .tile-body::after,
.tile-grid-ghosts .tile.tile--leaving,
+7 -5
View File
@@ -283,12 +283,14 @@ Object.assign(CodemanApp.prototype, {
wizardRect = wizardContent.getBoundingClientRect();
}
// Read tab rects for normal mode (only tabs that are actually needed)
// Read tab rects for normal mode (only tabs that are actually needed).
// Only a painted row is cached: a row a search or filter hid has no line
// (_paintedSessionTab), rather than one drawn from the viewport's corner.
if (!wizardOpen) {
for (const { agentId } of visibleSubagentWindows) {
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
}
}
@@ -397,7 +399,7 @@ Object.assign(CodemanApp.prototype, {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) {
// Tab not in DOM (might be scrolled out or session closed)
// Tab not painted (session closed, collapsed group, or hidden by a search)
continue;
}
@@ -691,8 +693,8 @@ Object.assign(CodemanApp.prototype, {
}
}
// Get parent TAB element for spawn animation
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Get parent TAB element for spawn animation (a hidden row spawns normally)
const parentTab = this._paintedSessionTab(parentSessionId);
// Create window element
const win = document.createElement('div');
+92 -15
View File
@@ -11,14 +11,20 @@
*
* Deliberately plainer than the primary pane (this.terminal/this._ws in
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
* handlers (a swipe on a touch screen pages nothing), no keyboard accessory
* bar, and no SGR wheel forwarding to Claude's fullscreen renderer
* (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see
* docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md.
* handlers (a swipe on a touch screen pages nothing), and no keyboard
* accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md
* and docs/tile-grid-plan.md.
*
* What it does carry over from the primary pane, through the primary pane's
* own code aimed at THIS pane (its terminal, its session, never the active
* one):
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
* renderer scrolls its own transcript on SGR wheel reports, while this
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
* as reports at the pointer's cell in this pane, through the primary
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
* primary pane does, since xterm turns it into a horizontal no-op.
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
* so the wheel pages the CLI's own transcript with PageUp/PageDown
@@ -43,7 +49,7 @@
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
*/
@@ -201,6 +207,9 @@
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
// whole page, carried to the next wheel event.
this._pageKeyPending = 0;
// Shift+wheel travel short of a whole line, carried to the next wheel
// event (_maybeScrollLocalOnShift).
this._shiftScrollPending = 0;
// Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
this._scrollBytes = '';
this._scrollFlushTimer = null;
@@ -1027,16 +1036,18 @@
// Capture phase, because xterm's own wheel handler stopPropagation()s every
// event it consumes, so a bubbling listener here would never see the wheel
// while the pane still has scrollback to scroll. Not passive: the one route
// this pane takes over, paging a hollow buffer's CLI transcript
// (_maybePageCliTranscript), is consumed right here (preventDefault plus
// while the pane still has scrollback to scroll. Not passive: the three
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
// stopPropagation in the capture phase, the primary pane's technique), so
// xterm's viewport, a descendant, never sees it. Every other wheel is left
// xterm's viewport, a descendant, never sees them. Every other wheel is left
// to xterm, which keeps doing the scrolling, and only observed for the
// shell history pull.
_installWheelListener() {
this._onWheel = (ev) => {
if (this._maybePageCliTranscript(ev)) {
if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
ev.preventDefault();
ev.stopPropagation();
return;
@@ -1046,6 +1057,71 @@
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
}
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
// no history for such a pane). Left to xterm, the wheel dragged those stale
// frames, Claude's pinned input box with them, up the tile, or scrolled
// nothing at all. The gate is the primary pane's own, asked for THIS pane
// (its terminal, its session, never the active one), so the CLI rules stay
// in terminal-ui.js and this file names no CLI; the reports go to this
// pane's session through its own coalescer. Returns true when the wheel
// belongs to the CLI: a gesture with no whole line or no measurable cell is
// consumed too, as in the primary pane, so xterm never scrolls the stale
// frames under a forwarding session. Shift fails the gate, so Shift+wheel
// still scrolls the local scrollback (_maybeScrollLocalOnShift).
_maybeForwardWheelToCli(ev) {
if (this._destroyed || !this.terminal || !ev) return false;
const app = global.app;
const input = global.CodemanTerminalInput;
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
// SGR coordinates address the live screen, so a report from a scrolled-up
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
const bytes = input.sgrWheelReports(lines, pos);
if (bytes) this._queueScrollBytes(bytes);
return true;
}
// Shift+wheel scrolls this xterm's local scrollback, the explicit "local
// history" gesture, here as in the primary pane (whose capture-phase wheel
// handler scrolls with terminal.scrollLines() for the same reason). Left to
// xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a
// HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical
// wheel into a horizontal one, so the viewport never moved. Reads the
// dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder
// for the next event (a trackpad's small deltas), and on the way up still
// asks a shell pane for more history. Returns true when the wheel was
// consumed here.
_maybeScrollLocalOnShift(ev) {
if (this._destroyed || !this.terminal || !ev?.shiftKey) return false;
const input = global.CodemanTerminalInput;
if (!input?.wheelDeltaLines) return false;
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
if (this.terminal.buffer?.active?.type === 'alternate') return false;
const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows);
const lines = Math.trunc(total);
this._shiftScrollPending = total - lines;
if (lines) {
this.terminal.scrollLines(lines);
if (lines < 0) this._maybeLoadMoreHistory();
}
return true;
}
// A plain left-click reported to the CLI, the primary pane's desktop click
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
@@ -1087,9 +1163,9 @@
const tracking = this.terminal.modes?.mouseTrackingMode;
if (tracking && tracking !== 'none') return false;
const target = { terminal: this.terminal, sessionId: this.sessionId };
// The primary pane would forward this wheel to Claude's fullscreen
// renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md
// follow-up 4), so the wheel stays with xterm, as before.
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
// forwarding session is never paged.
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
// Only from the live screen. The one gate the primary pane never needs: a
@@ -1111,8 +1187,9 @@
return true;
}
// Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a
// fling cannot build a backlog that keeps paging after it stops. A narrow
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
// that keeps scrolling after it stops. A narrow
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
// (terminal-ui.js; keep the two in step), which flushes to the active
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
+60 -30
View File
@@ -107,6 +107,32 @@
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
}
// The same travel rounded to whole lines for the SGR wheel reports: a pure
// horizontal swipe is 0 (nothing to send), and anything else moves at least
// one line, so the small pixel deltas of a precision touchpad still scroll.
// The body of the primary pane's _wheelScrollLines.
function wheelDeltaWholeLines(ev, rows) {
const lines = wheelDeltaLines(ev, rows);
if (!lines) return 0;
return Math.round(lines) || (lines > 0 ? 1 : -1);
}
// Ticks one gesture batch may report: Claude applies its own scroll-speed
// multiplier and acceleration on top, so a bigger batch only overshoots.
const SGR_WHEEL_MAX_TICKS = 5;
// Whole wheel lines → SGR wheel reports at a 1-based cell `pos` ({ col, row },
// live-screen relative): button 64 per line up, 65 per line down, capped at
// SGR_WHEEL_MAX_TICKS. '' when there is nothing to send. The encoding of the
// primary pane's _sendSyntheticSgrWheel, pure so a TerminalTile forwards
// byte-identical reports to its own session.
function sgrWheelReports(lines, pos) {
if (!lines || !pos) return '';
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), SGR_WHEEL_MAX_TICKS);
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
// `lines` to the sub-page travel already `pending`, and returns the travel
// left over plus the keys to send ('' below one page). The arithmetic of the
@@ -177,6 +203,18 @@
}
}
// Screen row of the cursor, for the local-echo prompt finders: xterm's cursorY is
// baseY-relative, so a viewport parked above the bottom shifts it. Null when off screen.
function cursorViewportRow(terminal) {
try {
const buf = terminal.buffer.active;
const row = buf.baseY + (buf.cursorY || 0) - buf.viewportY;
return row >= 0 && row < terminal.rows ? row : null;
} catch {
return null;
}
}
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
}
@@ -250,6 +288,7 @@
isComposerNavKey,
classifyPredictInput,
isCodexComposerRow,
cursorViewportRow,
CODEX_COMPOSER_ROW_RE,
BRACKETED_PASTE_START,
USER_SCROLL_STICKY_SUPPRESS_MS,
@@ -260,6 +299,9 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
wheelDeltaWholeLines,
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
pageKeysForTravel,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
@@ -2004,7 +2046,7 @@ Object.assign(CodemanApp.prototype, {
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are
// included so pasted-attachment paths (`.claude-images/paste-*.png`) and
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
// screenshots an agent just wrote are clickable; those open the file
// preview rather than the log viewer (see addLink).
//
@@ -3627,24 +3669,10 @@ Object.assign(CodemanApp.prototype, {
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
* the resulting position, and it is deliberately not folded into
* _noteTerminalUserScroll for exactly that reason.
*
* It is also what brings up the partial-history notice, and what retires it
* once a downward scroll is back at live output (_setHistoryNoticeRevealed).
* The reveal waits for the pull this gesture started, so the notice describes
* what the pull left rather than flashing the state it is about to replace.
*/
_maybeLoadMoreHistoryOnScroll(lines) {
if (lines > 0) {
if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null);
return;
}
if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return;
const sessionId = this.activeSessionId;
Promise.resolve(this._maybeRefetchFullHistory?.())
.catch(() => {})
.then(() => {
if (sessionId && this.activeSessionId === sessionId) this._setHistoryNoticeRevealed?.(sessionId);
});
if (lines >= 0) return;
if (this.terminal?.buffer?.active?.viewportY === 0) this._maybeRefetchFullHistory?.();
},
/**
@@ -4005,14 +4033,15 @@ Object.assign(CodemanApp.prototype, {
if (session.mode === 'opencode') {
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
// We use the cursor row (cursorY) to find the right line, then scan for ┃.
// We use the cursor's screen row to find the right line, then scan for ┃.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 3,
find: (terminal) => {
try {
const buf = terminal.buffer.active;
const row = buf.cursorY;
const row = window.CodemanTerminalInput.cursorViewportRow(terminal);
if (row === null) return null;
const line = buf.getLine(buf.viewportY + row);
if (!line) return null;
const text = line.translateToString(true);
@@ -4042,23 +4071,25 @@ Object.assign(CodemanApp.prototype, {
// the viewport, while xterm's cursor still marks the editable input
// position. Fall back to cursor coordinates so phone typing appears at
// the terminal cursor instead of disappearing into pending state.
// The glyph is looked for from the cursor's screen row up to the top of the
// live screen only: rows above that are parked scrollback with old composer glyphs.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 0,
find: (terminal) => {
try {
const buf = terminal.buffer.active;
for (let row = terminal.rows - 1; row >= 0; row--) {
const cursorRow = window.CodemanTerminalInput.cursorViewportRow(terminal);
if (cursorRow === null) return null;
const lowest = Math.max(0, buf.baseY - buf.viewportY);
for (let row = cursorRow; row >= lowest; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
const idx = text.lastIndexOf('\u276f');
if (idx >= 0) return { row, col: idx + 2 };
}
return {
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
};
return { row: cursorRow, col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)) };
} catch {
return null;
}
@@ -5577,9 +5608,8 @@ Object.assign(CodemanApp.prototype, {
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
// Claude mode the same value also capped the forwarded SGR report at one tick.
_wheelScrollLines(ev) {
const lines = this._wheelScrollLinesFloat(ev);
if (!lines) return 0; // pure horizontal swipe: don't fall through to -1
return Math.round(lines) || (lines > 0 ? 1 : -1);
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
},
/** Unrounded variant for the smooth local-scroll path, which accumulates
@@ -5653,13 +5683,13 @@ Object.assign(CodemanApp.prototype, {
// scroll-speed multiplier and acceleration on top), and the queue is bounded
// so a wild scroll can't build a backlog that keeps scrolling after the finger
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
_sendSyntheticSgrWheel(clientX, clientY, lines) {
if (!this.activeSessionId || !lines) return;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return;
const btn = lines < 0 ? 64 : 65;
const ticks = Math.min(Math.abs(lines), 5);
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
},
/**
+334 -123
View File
@@ -23,14 +23,18 @@
// Per-device tile font size (a tile is a fraction of the screen).
const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
// The grid this device last had, ids only (sanitizeTileGridState, constants.js):
// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, `ids` being the cells
// in reading order with `null` for an empty one. `open: false` keeps it
// remembered for one-click return; restored on reload inside handleInit.
// The grid this browser last had, as the user left it (sanitizeTileGridState,
// constants.js): `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`,
// `ids` being the cells in reading order with `null` for an empty one, `count`
// how many tiles the user's own last change left (a session that went away by
// itself does not lower it). Session ids and layout only, never content.
// Written on every change while the grid is open; `open: false` keeps it for
// the Tiles toggle, which brings it back exactly; a grid stored open is
// restored on reload inside handleInit. Never sent to the server.
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
// How many tiles a click on Tiles opens: the count last picked in its
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
// the stored grid above stays ids only.
// The count last picked in the Tiles button's right-click menu (2, 4 or 6;
// owner decision 10), per device: what a click opens when there is no stored
// grid to bring back, and what a pick re-forms the grid to.
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
// The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still
// copy of a closing grid goes even if animationend never comes (a hidden tab,
@@ -147,6 +151,11 @@ class TileGridModel {
// session id, or null for an empty cell. THE source of truth for where
// each tile is (owner: an empty cell can be any cell); `ids` derives from it.
this.cells = [];
// How many tiles the user's own last change left (open, add, remove, a
// count picked): a session that goes away by itself (deleted, popped out,
// refused) does not lower it, so the next time the grid opens the ranking
// fills that place. Stored with the grid (_persistTileGrid).
this.count = 0;
// id -> { tile: TerminalTile, el: HTMLElement }
this.tiles = new Map();
this.focusedId = null;
@@ -237,8 +246,15 @@ Object.assign(CodemanApp.prototype, {
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
entry.el.classList.toggle('tile--loading', state !== 'idle');
// The first capture has landed (or failed): the terminal fades in,
// whole, instead of showing its replay scroll by.
if (state === 'idle') entry.el.classList.remove('tile--revealing');
// whole, instead of showing its replay scroll by, or plays the
// terminal pane's entrance style (entrance-animations.js).
if (state === 'idle') {
entry.el.classList.remove('tile--revealing');
if (entry.screenOwed) {
entry.screenOwed = false;
this.playTileScreenEntrance?.(entry.body);
}
}
},
});
}
@@ -389,6 +405,9 @@ Object.assign(CodemanApp.prototype, {
wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k }));
// Packed from the first cell (_applyTileLayout pads the shape with empty cells).
grid.cells = wanted.filter((id) => grid.tiles.has(id));
// What the user opened is the grid they want (_openStoredTileGrid keeps a
// stored count instead).
grid.count = grid.ids.length;
this._applyTileLayout();
// The tiles' frames paint first; their terminals are built one per frame
// after it, the focused tile's first, so its capture is the one the queue
@@ -412,11 +431,13 @@ Object.assign(CodemanApp.prototype, {
* Leaves the grid: every tile destroyed (sockets closed, xterms disposed,
* queued loads dropped), the main terminal unparked.
*
* `keepStored` remembers the grid for one-click return (toggleTileGrid).
* `reselect` shows the focused session in the single view through a forced
* reload; pass false when the caller selects something itself. `animate`
* (the Tiles toggle only, owner answer 4) leaves a still copy of the tiles
* over the stage until the single view has its content (_ghostTileGrid).
* `keepStored` (every caller) keeps the grid as it was, holes, count, sizes,
* focus and zoom, closed, for the Tiles toggle to bring back (a reload
* restores only a grid stored open); false would forget it. `reselect` shows the focused session in the single
* view through a forced reload; pass false when the caller selects
* something itself. `animate` (the Tiles toggle only, owner answer 4)
* leaves a still copy of the tiles over the stage until the single view has
* its content (_ghostTileGrid).
*
* The main terminal's cached content for EVERY tiled id is invalidated: it was
* written before the grid opened, possibly hours ago, and selectSession paints
@@ -457,6 +478,7 @@ Object.assign(CodemanApp.prototype, {
grid.colFr = [];
grid.rowFr = [];
grid.cells = [];
grid.count = 0;
grid.cols = 0;
grid.rows = 0;
grid.focusedId = null;
@@ -536,14 +558,28 @@ Object.assign(CodemanApp.prototype, {
// Zoomed, only the zoomed tile is on screen (the others are display: none
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
const zoomed = section.classList.contains('tile-grid--zoomed');
const copies = [];
for (const id of grid.ids) {
const el = grid.tiles.get(id)?.el;
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
const ghost = el.cloneNode(true);
ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
ghost.classList.remove(
'tile--entering',
'tile--enter-themed',
'tile--enter-hold',
'tile--needs',
'tile--loading',
'tile--drop-target',
'tile--dragging'
);
ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
ghost.classList.add('tile--leaving');
layer.appendChild(ghost);
copies.push({ ghost, el, sessionId: id });
}
// The entrance style's own way out (entrance-animations.js): back into the
// tabs, a CRT switch-off. Placed while the real tiles are still measurable.
const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
main.appendChild(layer);
let fallback = null;
let holdCap = null;
@@ -557,10 +593,11 @@ Object.assign(CodemanApp.prototype, {
if (fallback !== null || !layer.isConnected) return;
clearTimeout(holdCap);
layer.classList.add('tile-grid-ghosts--release');
fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
};
layer.addEventListener('animationend', (e) => {
if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
if (e.pseudoElement || e.target !== layer.lastElementChild) return;
if (/^tile-leave/.test(e.animationName)) done();
});
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
else release();
@@ -695,8 +732,11 @@ Object.assign(CodemanApp.prototype, {
/**
* The card's text, from the remembered count, the grid's state and what the
* window fits. Compared with the last English text set, never the DOM (in
* zh-CN the DOM holds the translation; see _renderTileOverlay).
* window fits. With the grid closed and a stored grid to bring back, the
* click opens that grid as it was (never trimmed to the window), so the
* window line only says what fits. Compared with the last English text set,
* never the DOM (in zh-CN the DOM holds the translation; see
* _renderTileOverlay).
*/
_renderTileHint() {
const hint = this._tileHint;
@@ -712,10 +752,12 @@ Object.assign(CodemanApp.prototype, {
set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
const plural = capacity === 1 ? '' : 's';
const restoring = open ? null : this._storedTileGridSet();
const opens = restoring ? restoring.ids.length : count;
const fitsText =
count <= capacity
opens <= capacity
? ''
: open
: open || restoring
? `This window fits ${capacity} tile${plural}`
: `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
set(hint.fits.text, 'fitsText', fitsText);
@@ -976,22 +1018,25 @@ Object.assign(CodemanApp.prototype, {
/**
* A count picked in the menu: remembered for the click, then the grid opens
* with that many tiles (what the click opens), or the open grid is re-formed
* to it (_reformTileGrid). Never more than the window fits; fewer open
* sessions than the count give fewer tiles.
* with that many tiles (a stored grid re-formed to it, its tiles first in
* their cells), or the open grid is re-formed to it (_reformTileGrid).
* Never more than the window fits; fewer open sessions than the count give
* fewer tiles. Either way the grid's count is what the pick left.
*/
_pickTileCount(count) {
this.closeTileCountMenu({ refocus: false });
this._rememberTileGridCount(count);
const grid = this._tileGrid;
if (!grid?.open) {
this.toggleTileGrid();
this._activateTileGrid({ count });
return;
}
const T = window.CodemanTileGrid;
const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId));
// The tiles that join are the ranking's best (_tileGridRanking).
this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId));
grid.count = grid.ids.length;
this._persistTileGrid();
},
/**
@@ -1126,14 +1171,18 @@ Object.assign(CodemanApp.prototype, {
/**
* The Tiles button's click and Ctrl+Shift+G, one function so the two never
* drift (owner decision 8): opens the grid at once, no menu in the way, or
* closes it to the single view of the focused session. What opens is the
* remembered count of tiles (the right-click menu's last pick, default 6,
* at most what the window fits; owner decision 10), chosen by
* `tileGridOpenSet` (constants.js): the grid this tab last had, else an open
* split's two sessions, else the open sessions in tab order, the active one
* focused; then trimmed or filled to the count (the focused one kept). A
* remembered grid comes back with its tiles in their cells, the ones the
* count adds filling its empty cells first (_openStoredTileGrid).
* closes it to the single view of the focused session. The grid this
* browser last had comes back EXACTLY as the user left it (owner request:
* "when I turn tiles off and on, always keep what the last setting was"):
* its tiles in their cells, holes included, its count, divider sizes, focus
* and zoom; a cell whose session no longer exists is filled from the
* ranking (_openStoredTileGrid). With none of its sessions left (or nothing
* stored), `tileGridOpenSet` (constants.js) takes an open split's two
* sessions, else the open sessions as the ranking orders them
* (_tileGridRanking: working, then needing input, then the most recent),
* the active one always among them and focused, filled to the remembered
* count (the right-click menu's last pick, default 6, at most what the
* window fits; owner decision 10).
*/
toggleTileGrid() {
this.closeTileCountMenu();
@@ -1141,28 +1190,43 @@ Object.assign(CodemanApp.prototype, {
this.closeTileGrid({ keepStored: true, reselect: true, animate: true });
return;
}
if (!this.canOpenTileGrid()) return;
const stored = this._readStoredTileGrid();
const set = this._tileGridOpenSet(stored);
if (!set) {
this.showToast?.('No sessions to show as tiles', 'info');
return;
}
if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids);
else this.openTileGrid(set.ids, { focusedId: set.focusedId });
this._activateTileGrid();
},
/**
* What the toggle would open now (see toggleTileGrid): `count` tiles (the
* remembered count), or as many as the window fits; `stored` saves a
* second read.
* Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a
* pick in the count menu while the grid is closed), with that many tiles: a
* stored grid re-formed to it, its tiles first in their cells, the ones the
* count adds filling its empty cells first, the rest from the ranking.
*
* @returns {boolean} whether the grid opened
*/
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) {
_activateTileGrid({ count = null } = {}) {
if (!this.canOpenTileGrid()) return false;
const stored = this._readStoredTileGrid();
const set = this._tileGridOpenSet(stored, count);
if (!set) {
this.showToast?.('No sessions to show as tiles', 'info');
return false;
}
if (set.source === 'stored') return this._openStoredTileGrid(stored, set, { keepCount: count === null });
return this.openTileGrid(set.ids, { focusedId: set.focusedId });
},
/**
* What the toggle would open now (see toggleTileGrid): a stored grid as it
* was, or `count` tiles (the remembered count by default), at most what the
* window fits. With an explicit `count`, a stored grid is trimmed or filled
* to it too. `stored` saves a second read.
*/
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = null) {
const T = window.CodemanTileGrid;
const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity));
const n = Math.max(1, Math.min(count ?? this._tileGridCount(), this._tileGridLimit().capacity));
const ranked = this._tileGridRanking();
const set = T.tileGridOpenSet({
stored,
split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
ranked,
sessions: this.sessions,
sessionOrder: this.sessionOrder,
detachedIds: this.detachedSessions,
@@ -1170,8 +1234,42 @@ Object.assign(CodemanApp.prototype, {
limit: n,
});
if (!set) return null;
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) };
// A stored grid comes back as it was: the count does not apply to it
// unless one was asked for.
if (set.source === 'stored' && count === null) return set;
return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) };
},
/**
* The open sessions the grid takes when nobody said which (the Tiles button
* with nothing stored, and every tile it fills on its own), best first:
* working (the most recently started turn first), then the ones needing
* input (the red and yellow tab alerts), then the rest by most recent
* activity, tab order breaking ties (rankTileSessions, constants.js). The
* states and stamps are the home screens' own (`_mobileOverviewState()`,
* mobile-overview.js). Detached sessions are never in it. Guarded like
* the sorted rail: without the classifier (a stale cached
* mobile-overview.js) it is plain tab order.
*
* @returns {string[]}
*/
_tileGridRanking() {
const T = window.CodemanTileGrid;
const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions);
if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') {
return open.map((c) => c.id);
}
const rows = open.map(({ id }, orderIndex) => {
const session = this.sessions.get(id);
return {
id,
state: this._mobileOverviewState(session, this.pendingHooks?.get(id)),
lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 0,
orderIndex,
};
});
return T.rankTileSessions(rows);
},
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */
@@ -1242,6 +1340,7 @@ Object.assign(CodemanApp.prototype, {
if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false;
if (!this._mountTile(sessionId, { enterIndex })) return false;
this._placeTile(sessionId, cell);
grid.count = grid.ids.length;
// A tile added while one is zoomed by hand is meant to be seen.
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
this._applyTileLayout();
@@ -1286,17 +1385,20 @@ Object.assign(CodemanApp.prototype, {
* moves focus to the neighbouring tile (next in grid order, else previous),
* as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
* DOM focus where it is (an app-driven removal: a socket the server closed),
* so keystrokes never land in the neighbour's PTY unasked. The last tile
* leaving closes the grid: with `refocus` the single view then shows that
* session (or, popped out, the next one: _selectAfterTileGrid), without it
* the caller decides what comes next.
* so keystrokes never land in the neighbour's PTY unasked. `gone`: the
* session went away by itself (deleted, popped out, its socket refused), not
* by the user's hand, so the grid's count stays and the next time it opens
* the ranking fills that place. The last tile leaving closes the grid, kept
* as it was: with `refocus` the single view then shows that session (or,
* popped out, the next one: _selectAfterTileGrid), without it the caller
* decides what comes next.
*/
removeTile(sessionId, { refocus = true, focus = true } = {}) {
removeTile(sessionId, { refocus = true, focus = true, gone = false } = {}) {
const grid = this._tileGrid;
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
if (!entry) return false;
if (grid.ids.length === 1) {
this.closeTileGrid({ keepStored: false, reselect: refocus });
this.closeTileGrid({ keepStored: true, reselect: refocus });
return true;
}
const wasFocused = grid.focusedId === sessionId;
@@ -1312,6 +1414,7 @@ Object.assign(CodemanApp.prototype, {
entry.el.remove();
grid.tiles.delete(sessionId);
grid.cells[grid.cells.indexOf(sessionId)] = null;
if (!gone) grid.count = grid.ids.length;
// Before the layout, which may zoom the focused tile on a small window.
if (wasFocused) grid.focusedId = null;
this._applyTileLayout();
@@ -1323,11 +1426,10 @@ Object.assign(CodemanApp.prototype, {
/**
* Builds one tile (header, body, TerminalTile); where it goes is the
* caller's (grid.cells). It enters with a short fade and settle
* (styles.css .tile--entering, the `enterIndex`-th of a staggered group),
* and its terminal stays transparent until its first capture lands
* (.tile--revealing, cleared by the load queue, with a backstop timer).
* Opacity and transform only, never anything its fit reads.
* caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
* group (_beginTileEntrance), and its terminal stays transparent until its
* first capture lands (.tile--revealing, cleared by the load queue, with a
* backstop timer). Opacity and transform only, never anything its fit reads.
*/
_mountTile(sessionId, { enterIndex = 0 } = {}) {
const grid = this._tileGrid;
@@ -1336,16 +1438,11 @@ Object.assign(CodemanApp.prototype, {
const el = document.createElement('div');
el.className = 'tile tile--revealing';
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
if (this._tileMotionAllowed()) {
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
const onEnd = (e) => {
if (e.target !== el || e.animationName !== 'tile-enter') return;
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering');
};
el.addEventListener('animationend', onEnd);
}
// Its screen plays the terminal pane's entrance once its first capture
// lands, when its frame entered in a tile style (Tile Animations): never
// with the default `settle`, nor on a reload's restore, which settles.
const entered = this._beginTileEntrance(el, sessionId, enterIndex);
const screenOwed = !!entered && entered !== 'settle';
el.dataset.sessionId = sessionId;
// Header and body are siblings: the chrome is refreshed in place
// (_renderTileHeader), never by rewriting the tile, which would take the
@@ -1391,12 +1488,61 @@ Object.assign(CodemanApp.prototype, {
renaming: false,
// The pid this tile last saw, so a pane that starts later is noticed.
pid: this.sessions.get(sessionId)?.pid ?? null,
// Its screen plays the terminal entrance once its first capture lands.
screenOwed,
});
// Where it goes is the caller's (grid.cells).
this._renderTileHeader(sessionId);
return true;
},
/**
* A tile's frame enters as the `enterIndex`-th of a staggered group, in the
* entrance style (entrance-animations.js, App Settings → Entrance
* Animations). `settle` is the grid's own fade and settle (styles.css
* .tile--entering), and what a reload restores with whatever the theme:
* nothing animates on page load. Any other style is timed by the entrance
* module a frame later, once the layout is final (_stageTileEntrance).
* Opacity and transform only, never anything the fit reads.
*
* @returns {string|null} the style it enters with, or null when it does not animate
*/
_beginTileEntrance(el, sessionId, enterIndex = 0) {
const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
if (!this._tileMotionAllowed() || style === 'off') return null;
el.classList.add('tile--entering');
el.style.setProperty('--tile-enter-index', String(enterIndex));
let backstop = null;
const finish = () => {
clearTimeout(backstop);
el.removeEventListener('animationend', onEnd);
el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
};
// The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
// never a child's animation or a wash on its ::before.
const onEnd = (e) => {
if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
finish();
};
el.addEventListener('animationend', onEnd);
el._tileEnterFinish = finish;
if (style !== 'settle') {
this._stageTileEntrance?.(el, sessionId, (ms) => {
clearTimeout(backstop);
backstop = setTimeout(finish, ms);
});
}
return style;
},
/** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
_replayTileEntrance(el, sessionId, enterIndex = 0) {
el._tileEnterFinish?.();
void el.offsetWidth;
return this._beginTileEntrance(el, sessionId, enterIndex);
},
/**
* Makes `el` a drop target for a session tab dragged from the strip (the
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
@@ -1575,10 +1721,12 @@ Object.assign(CodemanApp.prototype, {
* Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
* human selection: the user clicked its tab). With the grid closed it opens
* what the Tiles toggle would, with this session among the tiles and
* focused: the remembered count in total (owner answer to decision 10's
* questions), never one more. Returns false
* when the grid cannot open here (narrow or solo window), so the click is an
* ordinary one.
* focused, never past the remembered count (owner answer to decision 10's
* questions: N, not N+1): it joins while the grid holds fewer than the
* count (a stored grid's first empty cell), else it takes the last tile's
* place. A stored grid keeps its cells, sizes and holes around it.
* Returns false when the grid cannot open here (narrow or solo window), so
* the click is an ordinary one.
*/
addSessionToTiles(sessionId) {
if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) {
@@ -1599,7 +1747,26 @@ Object.assign(CodemanApp.prototype, {
return true;
}
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
const base = this._tileGridOpenSet()?.ids || [];
const stored = this._readStoredTileGrid();
const plan = this._tileGridOpenSet(stored);
if (plan?.source === 'stored') {
let { ids, cells } = plan;
if (!ids.includes(sessionId)) {
if (ids.length < n) {
ids = [...ids, sessionId];
} else {
const last = ids.at(-1);
ids = ids.map((id) => (id === last ? sessionId : id));
cells = cells.map((id) => (id === last ? sessionId : id));
}
}
return this._openStoredTileGrid(
stored,
{ ids, cells, focusedId: sessionId },
{ focusedId: sessionId, auto: false, keepCount: false }
);
}
const base = plan?.ids || [];
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
// Exactly these: an open split is already in `base` (tileGridOpenSet seeds
// it), and merging it again went past the count (N+1) and the window.
@@ -1642,7 +1809,8 @@ Object.assign(CodemanApp.prototype, {
_replaceTileGrid(ids) {
const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0];
if (this._tilesOwnTerminal()) {
this.closeTileGrid({ keepStored: false, reselect: false });
// Kept until the group's grid, opened next, takes its place.
this.closeTileGrid({ keepStored: true, reselect: false });
this.activeSessionId = null;
}
// Exactly the group: an open split closes without joining it (merged, its
@@ -2127,7 +2295,7 @@ Object.assign(CodemanApp.prototype, {
this._renderTileOverlay(sessionId);
return;
}
this.removeTile(sessionId, { focus: false });
this.removeTile(sessionId, { focus: false, gone: true });
},
/**
@@ -2481,17 +2649,20 @@ Object.assign(CodemanApp.prototype, {
this._persistTileGrid();
},
// ── Persistence (codeman:tile-grid, per device, ids only) ────────────────
// ── Persistence (codeman:tile-grid, per browser: ids and layout, never content) ──
/**
* Writes the open grid: ids, focus, a zoom the user chose (an automatic one
* is worked out again from the window) and the divider fractions. Never
* content. `open: false` is the closed-but-remembered state. Never in a solo
* window; a storage failure only costs the convenience.
* Writes the open grid as it is, on every change (a move, a divider drag, a
* tile added or removed, a count picked, a focus, a zoom): its cells, its
* count, focus, a zoom the user chose (an automatic one is worked out again
* from the window) and the divider fractions. Never content, never to the
* server. `open: false` is the closed-but-remembered state. Never in a solo
* window, nor while a stored grid is being put back (_openStoredTileGrid
* writes once it is); a storage failure only costs the convenience.
*/
_persistTileGrid({ open = true } = {}) {
const grid = this._tileGrid;
if (this.isSoloWindow || !grid || grid.ids.length === 0) return;
if (this.isSoloWindow || this._tilePersistHold || !grid || grid.ids.length === 0) return;
if (open && !grid.open) return;
const state = {
v: 1,
@@ -2500,6 +2671,9 @@ Object.assign(CodemanApp.prototype, {
// (sanitizeTileGridState; a build before cells drops the nulls and reads
// the tiles packed, as it always did).
ids: grid.cells.slice(),
// Never fewer than the tiles shown (a build before `count` derives it
// from `ids`, and ignores it).
count: Math.min(Math.max(grid.count || 0, grid.ids.length), window.CodemanTileGrid.TILE_GRID_MAX),
focused: grid.focusedId,
zoomed: grid.autoZoom ? null : grid.zoomedId,
colFr: grid.colFr.slice(),
@@ -2534,47 +2708,74 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Opens a stored grid: its tiles and focus, then the fractions it had (only
* if they still match the layout) and a zoom the user chose. `auto`: the app
* is putting it back, so no idle alert is spent. `ids` is the set to open,
* the stored one by default (a reload); the Tiles button passes it trimmed
* or filled to the remembered count, a shape change the cell model's rule
* handles (reformTileCells).
* The stored grid as it would come back now (restoreTileGridCells): its
* cells as stored, a cell whose session no longer exists filled from the
* ranking while the grid holds fewer than its count. Null when nothing is
* stored or none of its sessions survive.
*/
_openStoredTileGrid(stored, ids = stored.ids) {
const focus = stored.zoomed || stored.focused;
// The stored set (an open split closes without joining it, decision 8,
// case a), trimmed or filled to the count by the caller.
if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false;
const grid = this._tileGrid;
_storedTileGridSet(stored = this._readStoredTileGrid()) {
if (!stored) return null;
return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking());
},
/**
* Opens a stored grid as the user left it: `set` (the stored cells with any
* freed cell filled, _storedTileGridSet, by default; the Tiles button may
* pass it trimmed or filled to a picked count, or with a Ctrl/Cmd+clicked
* session in it) goes back into its cells, holes included, a shape change
* following the cell model's rule (reformTileCells: the tiles keep their row
* and column when all fit, the ones that join fill the empty cells in
* reading order); then the fractions it had (only if they still match the
* layout) and a zoom the user chose on the focused tile. A grid larger than
* the window fits is never trimmed: the focused tile shows alone until the
* window fits it (_applyTileLayout), and the arrangement stays. `auto`: the
* app is putting it back, so no idle alert is spent. `keepCount`: the
* stored count stays the grid's (a session that went away may be filled the
* next time); false makes it what opens (a pick, a Ctrl/Cmd+click). Nothing
* is written until the grid is back, so a stored grid is never overwritten
* by a half-built one.
*/
_openStoredTileGrid(
stored,
set = this._storedTileGridSet(stored),
{ focusedId = null, auto = true, keepCount = true } = {}
) {
if (!set?.ids?.length) return false;
const T = window.CodemanTileGrid;
// openTileGrid packed the tiles from the first cell. The stored tiles go
// back to their cells (a session gone since leaves its cell empty): as
// they were when this is the stored set in the shape it was stored with,
// otherwise by the cell model's rule, the tiles the count adds filling the
// empty cells in reading order, holes first.
const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
const kept = storedCells.filter(Boolean);
const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id));
const cells = sameSet
? storedCells
: T.reformTileCells(
storedCells,
T.tileCellCols(storedCells.length),
kept,
grid.ids.filter((id) => !kept.includes(id)),
grid.cols,
grid.rows
);
const focus = focusedId || set.focusedId;
// An open split closes without joining it (decision 8, case a).
this._tilePersistHold = true;
let opened = false;
try {
opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false });
} finally {
this._tilePersistHold = false;
}
if (!opened) return false;
const grid = this._tileGrid;
// openTileGrid packed the tiles from the first cell. They go back to their
// cells (a session gone since leaves its cell empty, unless the ranking
// filled it).
const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
const kept = base.filter(Boolean);
const cells = T.reformTileCells(
base,
T.tileCellCols(base.length),
kept,
grid.ids.filter((id) => !kept.includes(id)),
grid.cols,
grid.rows
);
if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
grid.cells = cells;
}
grid.count = keepCount ? Math.min(Math.max(grid.ids.length, stored.count || 0), T.TILE_GRID_MAX) : grid.ids.length;
// openTileGrid laid the grid out with equal tracks. The stored ones go back
// on; _applyTileLayout drops them again if they do not match the column or
// row count (the window may have changed the layout since).
if (stored.colFr) grid.colFr = stored.colFr.slice();
if (stored.rowFr) grid.rowFr = stored.rowFr.slice();
if (stored.zoomed && grid.tiles.has(stored.zoomed)) {
if (stored.zoomed && stored.zoomed === grid.focusedId && grid.tiles.has(stored.zoomed)) {
grid.zoomedId = stored.zoomed;
grid.autoZoom = false;
}
@@ -2598,17 +2799,27 @@ Object.assign(CodemanApp.prototype, {
if (this._tilesOwnTerminal()) return false;
const stored = this._readStoredTileGrid();
if (!stored?.open || stored.ids.length === 0) return false;
return this._openStoredTileGrid(stored);
// Put back, not opened: the tiles settle in whatever the entrance theme.
this._tileEnterQuiet = true;
try {
return this._openStoredTileGrid(stored);
} finally {
this._tileEnterQuiet = false;
}
},
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
/**
* A followed `#session=` link took the screen on load: the stored grid stays
* remembered, closed. Only `open` changes: the value is written back as it
* was stored, so a session gone since still frees its cell for the ranking
* the next time the grid opens.
*/
_closeStoredTileGrid() {
const stored = this._readStoredTileGrid();
if (!stored?.open) return;
// Stored as it was read, holes included (`ids` carries the cells).
const { cells, ...rest } = stored;
try {
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false }));
const raw = JSON.parse(localStorage.getItem(TILE_GRID_STORAGE_KEY));
localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...raw, open: false }));
} catch {
/* Per-device convenience only. */
}
@@ -2637,7 +2848,7 @@ Object.assign(CodemanApp.prototype, {
const grid = this._tileGrid;
if (!grid?.open) return false;
for (const id of grid.ids.slice()) {
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false });
if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false, gone: true });
}
if (!grid.open) return false;
// Only a focus that is gone moves: re-selecting the same tile would hide an
@@ -2665,7 +2876,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
if (grid?.has(data.id)) {
const wasFocused = grid.focusedId === data.id;
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
this.removeTile(data.id, { refocus: false });
this.removeTile(data.id, { refocus: false, gone: true });
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
this._selectTiledSession(neighbor, { auto: true, focus: false });
}
+9 -6
View File
@@ -194,8 +194,8 @@ Object.assign(CodemanApp.prototype, {
</div>
`;
// Position: spawn from the parent tab if we can find it, else cascade.
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Position: spawn from the parent tab if it is painted, else cascade.
const parentTab = this._paintedSessionTab(parentSessionId);
if (parentTab) {
// _tabAnchor() puts the spawn point below the tab in header layout and to
// the RIGHT of it in sidebar layout, so the window never lands on the
@@ -312,9 +312,12 @@ Object.assign(CodemanApp.prototype, {
});
},
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
/**
* Genie the window toward the center of its tab, then invoke `done` to tear it
* down. A tab that is not painted (hidden by a search) tears down at once.
*/
_animateUltracodeWindowToTab(element, sessionId, done) {
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
const tab = this._paintedSessionTab(sessionId);
if (!tab || !element) {
done();
return;
@@ -781,7 +784,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
@@ -830,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
const tabRect = rects.get(tabKey);
+92 -7
View File
@@ -29,7 +29,11 @@
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
*/
import { spawn } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
@@ -207,14 +211,65 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
*/
const installsInFlight = new Set<string>();
const execFileAsync = promisify(execFile);
/**
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
*
* Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on
* timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is
* judged by the nearest ancestor that does: npm creates the missing directories, so a user
* `.npmrc` pointing at `~/.npm-global` before it was made is not moved.
*/
export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> {
try {
const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], {
env: source,
encoding: 'utf8',
timeout: 5_000,
killSignal: 'SIGKILL',
});
const prefix = stdout.trim();
if (!prefix) return true;
// npm creates lib/node_modules under the prefix; walk up to the first directory that exists.
let dir = join(prefix, 'lib', 'node_modules');
for (;;) {
try {
await access(dir, fsConstants.W_OK);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
const parent = dirname(dir);
if (parent === dir) return false;
dir = parent;
}
}
} catch {
return true;
}
}
/**
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
* so an `npm install -g` lands on the persistent home mount instead of the image.
* data dir, the tmux socket), none of which an installer needs.
*
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
* update (the image's own prefix is image content);
* - on a native install whose npm global prefix is not writable by the server user (a system node
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
*/
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
export function installEnv(
source: NodeJS.ProcessEnv = process.env,
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('CODEMAN_')) env[key] = value;
@@ -224,11 +279,40 @@ export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
redirectNpmPrefix(env, source.HOME);
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
redirectNpmPrefix(env, source.HOME);
}
return env;
}
/**
* Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a
* lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings
* are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit
* operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one.
*/
function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void {
for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key];
env.NPM_CONFIG_PREFIX = `${home}/.local`;
}
/**
* `installEnv` for this process, with the (async) npm prefix probe done first and only when the
* command runs npm at all: a `curl | bash` installer never pays for it.
*/
async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> {
const usesNpm = /\bnpm\b/.test(command);
const probeNeeded =
usesNpm &&
source.CODEMAN_IN_CONTAINER !== '1' &&
process.platform !== 'win32' &&
!!source.HOME &&
!source.NPM_CONFIG_PREFIX;
const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true;
return installEnv(source, () => writable);
}
interface InstallResult {
code: number | null;
output: string;
@@ -244,6 +328,7 @@ interface InstallResult {
* CUSTOM entry can never reach this function at all — see the route's own guard below.
*/
async function runInstallCommand(command: string): Promise<InstallResult> {
const env = await installEnvFor(command);
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
@@ -255,7 +340,7 @@ async function runInstallCommand(command: string): Promise<InstallResult> {
// out into package-manager children, and spawn's own `timeout` option signals
// only the direct child, leaving survivors holding the pipes open forever.
detached: true,
env: installEnv(),
env,
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
+10 -3
View File
@@ -10,7 +10,8 @@
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
* or when its config file exists); one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
@@ -28,6 +29,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
import { mcpSyncOnlyTargets } from '../../mcp-sync-targets.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
@@ -35,9 +37,13 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
return settings.mcpSyncEnabled === true;
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
/**
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
* definition wins a same-name difference.
*/
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
return enabledClis()
const registry = enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
@@ -46,6 +52,7 @@ export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTa
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
return [...registry, ...mcpSyncOnlyTargets(new Set(registry.map((t) => t.id)))];
}
/**
+1 -1
View File
@@ -275,7 +275,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
// count this session's historical tokens into the lifetime totals, demote
// a pinned record to `stopped` (which this pass reads as an intentional
// kill, making the session permanently unrestorable) and delete the
// workspace's `.claude-images`. This undoes only the construction.
// workspace's `.codeman-uploads`. This undoes only the construction.
await ctx
.discardPartiallyBuiltSession(entry.sessionId)
.catch((discardErr: unknown) =>
+65 -37
View File
@@ -188,6 +188,7 @@ import {
} from '../response-viewer-transcript.js';
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
import { UPLOADS_DIR } from '../paste-image-gc.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -361,6 +362,51 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
}
}
/**
* Create or re-verify `{workingDir}/.codeman-uploads` for a prompt upload, or
* null when something other than a regular directory sits there. An agent or
* postinstall script could plant `.codeman-uploads -> ~/.ssh/` and redirect
* future writes outside workingDir: lstat (not stat) sees the symlink itself,
* and mkdir without `recursive` does not follow one for the leaf either;
* O_EXCL|O_NOFOLLOW on the file open makes the write itself symlink-safe.
* The folder ignores itself: a `.gitignore` of `*`, written once with O_EXCL,
* so the user's repository never sees uploads, their own ignore file is never
* touched, and a file already there is theirs and stays as it is.
*/
async function ensureUploadDir(workingDir: string): Promise<string | null> {
// workingDir is guaranteed to exist (live session).
const uploadDir = join(workingDir, UPLOADS_DIR);
try {
const dirStat = await fs.lstat(uploadDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) return null;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
try {
await fs.mkdir(uploadDir);
} catch (mkErr: unknown) {
// Concurrent uploads (a batch of photos) race to create the dir — the
// losers get EEXIST. Treat an already-present REAL directory as success,
// but re-verify it isn't a symlink a racing actor planted.
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
const raceStat = await fs.lstat(uploadDir);
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) return null;
}
}
const ignoreFile = join(uploadDir, '.gitignore');
try {
// 'wx' = O_CREAT|O_EXCL, which also fails on a symlink at the path.
await fs.writeFile(ignoreFile, '*\n', { flag: 'wx' });
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return uploadDir;
// The create can succeed before the write fails (ENOSPC): an empty ignore
// file would read as the user's on the next upload, so take it back; its own
// failure must not replace the cause.
await fs.rm(ignoreFile, { force: true }).catch(() => {});
throw err;
}
return uploadDir;
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys.
@@ -3225,12 +3271,6 @@ export function registerSessionRoutes(
// damage that does not exist.
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
// Rows tmux holds above the visible frame, which is the most a `full=1`
// pull can add. `truncated` measures the BYTE stream, and for a pane that
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
// a tail cut drops are old repaints that no request can bring back, so a
// client must not offer to load them. Absent when the pane was not read.
paneHistoryLines: hasLiveMuxBuffer ? captureOpts.capturedHistoryLines : undefined,
};
});
@@ -5204,6 +5244,20 @@ export function registerSessionRoutes(
const session = findSessionOrFail(ctx, id, req);
// The file lands on THIS host under the session's working directory, which
// for a remote (SSH) session is the remote path: the agent there could never
// read it, and the write would land in a same-named local directory or fail.
// An owned Docker case is fine, its workspace is bind-mounted at the same
// absolute path; an adopted container (owned: false) mounts nothing, so its
// agent reads the file only if the container exposes that host path.
if (session.remote) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Prompt uploads are not supported for remote (SSH) sessions'
);
}
if (!req.isMultipart()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -5300,37 +5354,11 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
// Save to {workingDir}/.claude-images/
// Refuse symlinks at imageDir — an agent or postinstall script could plant
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
// We lstat (not stat) so we see the symlink itself. Use mkdir without
// `recursive` so the leaf creation does not follow a symlink either, and
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
const imageDir = join(session.workingDir, '.claude-images');
try {
const dirStat = await fs.lstat(imageDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// Non-recursive mkdir: does not follow symlinks for the leaf.
// session.workingDir is guaranteed to exist (live session).
try {
await fs.mkdir(imageDir);
} catch (mkErr: unknown) {
// Concurrent uploads (a batch of photos) race to create .claude-images —
// the losers get EEXIST. Treat an already-present REAL directory as
// success, but re-verify it isn't a symlink a racing actor planted
// (preserve the symlink-safety guarantee above).
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
const raceStat = await fs.lstat(imageDir);
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
}
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
const imageDir = await ensureUploadDir(session.workingDir);
if (!imageDir) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
+7
View File
@@ -1391,6 +1391,13 @@ export const SettingsUpdateSchema = z
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
/**
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
* false hides the agent logo on session tabs and the desktop home rail
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
* and the Run menus keep their logos.
*/
showTabCliLogos: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
tabRailDetail: z.enum(['simple', 'rich']).optional(),
+14 -9
View File
@@ -33,11 +33,11 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname, uptime as osUptime } from 'node:os';
@@ -1546,11 +1546,16 @@ export class WebServer extends EventEmitter {
killing: this.killingSessions,
})
) {
const pasteImageDir = join(session.workingDir, '.claude-images');
try {
rmSync(pasteImageDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
// Both upload dirs, the pre-move one too; uploadDirs() lists only real
// directories that are not the data dir and do not contain it, none for a
// remote session, and nothing on a workspace whose bounded probe did not
// answer (pastCap: this acts on one path at the user's request).
for (const uploadDir of await uploadDirs(session, { pastCap: true })) {
try {
await fs.rm(uploadDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
}
}
// Drop the agent skill's preamble cache for this session (seeded at create).
@@ -2911,7 +2916,7 @@ export class WebServer extends EventEmitter {
}
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
// older than 7 days from each live session's upload dirs hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
@@ -3363,7 +3368,7 @@ export class WebServer extends EventEmitter {
* path adds the session's token totals to the lifetime figures, demotes a
* pinned record to `stopped` (the durable marker of an intentional kill, which
* would make the session permanently ineligible for a reboot restore), drops
* the persisted Ralph state, and recursively removes `.claude-images` from the
* the persisted Ralph state, and recursively removes the upload dirs from the
* WORKING DIRECTORY, which belongs to the workspace rather than to this session
* and may hold another live session's pasted images.
*
+3 -4
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Phase 5 admin API tests (live server, port 3173).
* @fileoverview Phase 5 admin API tests (live server, ephemeral port).
*
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
* create (one-time password), patch + last-admin invariant, reset-password,
@@ -16,9 +16,8 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const PORT = 3173;
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
const url = (p: string) => `http://localhost:${PORT}${p}`;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
@@ -48,7 +47,7 @@ beforeAll(async () => {
invalidateUsersCache();
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
server = new WebServer(PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
});
+25
View File
@@ -80,6 +80,31 @@ describe('App Settings modal structure', () => {
expect(system).toContain('id="appSettingsTunnelEnabled"');
});
/**
* Owner decision (2026-10-09): every animation setting has its own
* Animations section, right after Appearance, so it is easy to find. The
* selects are wired by id in entrance-animations.js, not by the load/save
* path above, so they get their own check here.
*/
it('keeps every animation setting in its own Animations section, after Appearance', () => {
const modal = settingsModal();
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
for (const list of [rail, order]) {
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
}
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
}
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
expect(anim).toContain(`document.getElementById('${id}')`);
}
});
it('keeps Local Echo the first row of the second section', () => {
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
+4 -4
View File
@@ -8,14 +8,12 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
const PORT = 3197;
describe('reverse-proxy base path: server wiring', () => {
let server: WebServer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let app: any;
beforeAll(async () => {
server = new WebServer(PORT, false, true, '127.0.0.1', undefined, false, '/codeman');
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
await server.start();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app = (server as any).app;
@@ -66,7 +64,9 @@ describe('reverse-proxy base path: server wiring', () => {
const { WebSocket } = await import('ws');
const close = (path: string) =>
new Promise<{ code: number; reason: string }>((resolve) => {
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { headers: { origin: `http://127.0.0.1:${PORT}` } });
const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, {
headers: { origin: `http://127.0.0.1:${server.boundPort}` },
});
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
ws.on('error', (e) => resolve({ code: -1, reason: String(e) }));
});
+5 -5
View File
@@ -20,7 +20,7 @@
* because the mismatch itself needs two viewports to stage against live tmux.
* Without the fix the first assertion below sees one fetch instead of two.
*
* Port: 3252 (capture geometry retry)
* Port: ephemeral
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
*/
@@ -29,15 +29,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3252;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode
server = new WebServer(0, false, true); // testMode
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 60_000);
@@ -176,7 +176,7 @@ async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; url
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
async function openSession(page: Page): Promise<string> {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
// Without it `app.terminal.rows` reads 0 and every height comparison below
+5 -5
View File
@@ -14,7 +14,7 @@
* own `json()` call, which is the one place guaranteed to land after the
* headers and before the chunked write.
*
* Port: 3256 (capture load window)
* Port: ephemeral
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
*/
@@ -23,16 +23,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3256;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode
server = new WebServer(0, false, true); // testMode
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
}, 60_000);
@@ -115,7 +115,7 @@ async function runLoad(page: Page, sessionId: string, source: string): Promise<n
}
async function openSession(page: Page): Promise<string> {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm loads from /vendor, so the terminal appears a beat after the app.
// Without it every buffer assertion below would throw rather than compare.
+2 -4
View File
@@ -8,8 +8,6 @@ import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
const PORT = 3193;
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
@@ -18,11 +16,11 @@ describe('Create a case in a custom folder', () => {
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
server = new WebServer(PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
+711
View File
@@ -0,0 +1,711 @@
/**
* @fileoverview `codeman agent …` — the three invariants from `src/cli-agent.ts`
* plus every verb against a recording fake transport, and the real HTTP transport
* against a local server (headers, auth, query encoding).
*/
import http from 'node:http';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
EXIT,
agentInterrupt,
agentLs,
agentRead,
agentRm,
agentSend,
agentSpawn,
agentWait,
baseHeaders,
sendPromptFromArgs,
composerReadyMark,
buildInterruptBody,
buildSendBody,
deleteRefusal,
describeFailure,
httpRequest,
inputRefusal,
isSelfSession,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
registerAgentCommands,
resolveAgentContext,
stripAnsi,
waitExitCode,
type AgentContext,
type AgentDeps,
type ApiResponse,
type RequestOptions,
} from '../src/cli-agent.js';
import { readCodemanEnvFile } from '../src/codeman-credentials.js';
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
function ctx(overrides: Partial<AgentContext> = {}): AgentContext {
return { apiUrl: 'http://127.0.0.1:1', selfId: SELF, ...overrides };
}
/** Recording transport: answers from a queue (or a resolver) and keeps every call. */
function fakeDeps(
answer: ((options: RequestOptions) => ApiResponse) | ApiResponse[],
json = false
): AgentDeps & { calls: RequestOptions[]; out: string[]; err: string[] } {
const calls: RequestOptions[] = [];
const out: string[] = [];
const err: string[] = [];
const queue = Array.isArray(answer) ? [...answer] : undefined;
return {
ctx: ctx(),
calls,
out,
err,
json,
now: () => 1_700_000_000_000,
io: { out: (l) => out.push(l), err: (l) => err.push(l) },
request: async (_c, options) => {
calls.push(options);
if (queue) {
const next = queue.shift();
if (!next) throw new Error('fake transport: no answer queued');
return next;
}
return (answer as (o: RequestOptions) => ApiResponse)(options);
},
};
}
function ok(data: unknown): ApiResponse {
return { status: 200, json: { success: true, data }, text: '' };
}
function apiError(status: number, errorCode: string, error: string): ApiResponse {
return { status, json: { success: false, errorCode, error }, text: '' };
}
// ─────────────────────────────────────────────────────────────────────────────
// Invariant 1: the guard
// ─────────────────────────────────────────────────────────────────────────────
describe('resolveAgentContext (guard)', () => {
const inside = { CODEMAN_MUX: '1', CODEMAN_API_URL: 'http://127.0.0.1:3459', CODEMAN_SESSION_ID: SELF };
it('refuses outside a Codeman session', () => {
expect(() => resolveAgentContext({}, () => ({}))).toThrow(AgentGuardError);
expect(() => resolveAgentContext({ ...inside, CODEMAN_MUX: '0' }, () => ({}))).toThrow(/CODEMAN_MUX/);
});
it('never guesses an API URL', () => {
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: '' }, () => ({}))).toThrow(/refusing to guess/);
expect(() => resolveAgentContext({ ...inside, CODEMAN_API_URL: undefined }, () => ({}))).toThrow(AgentGuardError);
});
it('needs its own session id to tell self from others', () => {
expect(() => resolveAgentContext({ ...inside, CODEMAN_SESSION_ID: '' }, () => ({}))).toThrow(/CODEMAN_SESSION_ID/);
});
it('takes the password from the environment first, the .env file second, and none means open', () => {
expect(
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_PASSWORD: 'file' })).auth
).toEqual({
username: 'admin',
password: 'pw',
});
expect(resolveAgentContext(inside, () => ({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 'file' })).auth).toEqual({
username: 'joe',
password: 'file',
});
expect(resolveAgentContext(inside, () => ({})).auth).toBeUndefined();
});
it('resolves each field the way attach and the TUI do (one shared order)', () => {
// Password from the environment, username from the file: joe, not admin.
expect(
resolveAgentContext({ ...inside, CODEMAN_PASSWORD: 'pw' }, () => ({ CODEMAN_USERNAME: 'joe' })).auth
).toEqual({ username: 'joe', password: 'pw' });
});
it('reads a hand-authored .env with quotes and export prefixes', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-agent-env-'));
try {
const file = join(dir, '.env');
writeFileSync(file, '# comment\nexport CODEMAN_USERNAME="joe"\nCODEMAN_PASSWORD=\'s3cret\'\nnot a line\n');
expect(readCodemanEnvFile(file)).toEqual({ CODEMAN_USERNAME: 'joe', CODEMAN_PASSWORD: 's3cret' });
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Invariant 2: send is printable text + \r; ESC lives only in interrupt
// ─────────────────────────────────────────────────────────────────────────────
describe('send transmits printable text only', () => {
it('refuses every control byte and DEL, naming it', () => {
expect(inputRefusal('\u0003')).toMatch(/0x03/); // Ctrl+C: opencode's app_exit
expect(inputRefusal('ls\u001b')).toMatch(/0x1b/);
expect(inputRefusal('a\u007fb')).toMatch(/0x7f/);
expect(inputRefusal('two\nlines')).toMatch(/single line/); // not the ESC hint
expect(inputRefusal('a\tb')).toMatch(/single line/);
expect(inputRefusal('x\u009bmy')).toMatch(/0x9b/); // 8-bit CSI
expect(inputRefusal('')).toMatch(/empty/);
});
it('accepts ordinary prompts, including unicode', () => {
expect(inputRefusal('review the diff in src/, then say DONE_4711')).toBeUndefined();
expect(inputRefusal('prüfe die Ändërung ❯ ok')).toBeUndefined();
});
it('appends exactly one \\r, or nothing with --no-enter, and never anything else', () => {
const base = { clientId: 'c', seq: 1 };
expect(buildSendBody('hi', { ...base, enter: true }).input).toBe('hi\r');
expect(buildSendBody('hi', { ...base, enter: false }).input).toBe('hi');
const body = buildSendBody('hi', { ...base, enter: true, wait: 'stop,exit', waitTimeout: 5000 });
expect(body).toEqual({ input: 'hi\r', useMux: true, clientId: 'c', seq: 1, wait: 'stop,exit', waitTimeout: 5000 });
expect(buildSendBody('hi', { ...base, enter: true })).not.toHaveProperty('wait');
});
it('interrupt is a bare ESC with no Enter', () => {
const body = buildInterruptBody('c-interrupt', 7);
expect(body.input).toBe('\u001b');
expect(String(body.input)).not.toContain('\r');
expect(body).toEqual({ input: '\u001b', useMux: true, clientId: 'c-interrupt', seq: 7 });
});
it('agentSend refuses control bytes BEFORE touching the transport', async () => {
const deps = fakeDeps([]);
expect(await agentSend(deps, { id: OTHER, text: 'q\u0003', enter: true })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Invariant 3: rm fails closed
// ─────────────────────────────────────────────────────────────────────────────
describe('rm fails closed', () => {
it('refuses an empty id, a short self id, and a prefix match in either direction', () => {
expect(deleteRefusal(SELF, '')).toMatch(/empty/);
expect(deleteRefusal('058ee7', OTHER)).toMatch(/too short/);
expect(deleteRefusal(SELF, SELF)).toMatch(/is me/);
expect(deleteRefusal(SELF, SELF.slice(0, 8))).toMatch(/is me/); // 8-char form of me
expect(deleteRefusal(SELF.slice(0, 8), SELF)).toMatch(/is me/); // Docker's truncated $SELF
expect(deleteRefusal(SELF, OTHER)).toBeUndefined();
expect(deleteRefusal(SELF, OTHER.slice(0, 8))).toBeUndefined();
});
it('isSelfSession treats an unprovable self as "maybe me"', () => {
expect(isSelfSession('short', OTHER)).toBe(true);
expect(isSelfSession(SELF, '')).toBe(true);
expect(isSelfSession(SELF, OTHER)).toBe(false);
});
it('agentRm never calls DELETE on a refusal', async () => {
const deps = fakeDeps([]);
expect(await agentRm(deps, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('agentRm deletes a foreign id plainly (killMux default, no query)', async () => {
const deps = fakeDeps([ok({})]);
expect(await agentRm(deps, { id: OTHER })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ method: 'DELETE', path: `/api/v1/sessions/${OTHER}` });
expect(deps.calls[0].query).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Verbs against the fake transport
// ─────────────────────────────────────────────────────────────────────────────
describe('agent ls', () => {
const sessions = [
{ id: SELF, mode: 'claude', status: 'busy', name: 'w1-Codeman' },
{ id: OTHER, mode: 'opencode', status: 'idle', workingDir: '/home/joe/wiki' },
];
it('marks this session and falls back to workingDir for the name', async () => {
const deps = fakeDeps([ok(sessions)]);
expect(await agentLs(deps)).toBe(EXIT.ok);
const text = deps.out.join('\n');
expect(text).toMatch(/\*\s+058ee7b5\s+claude\s+busy\s+w1-Codeman/);
expect(text).toMatch(/94990c6d\s+opencode\s+idle\s+\/home\/joe\/wiki/);
});
it('--json is the envelope data plus a self flag', async () => {
const deps = fakeDeps([ok(sessions)], true);
await agentLs(deps);
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; self: boolean }>;
expect(parsed.map((s) => [s.id.slice(0, 8), s.self])).toEqual([
['058ee7b5', true],
['94990c6d', false],
]);
});
it('surfaces a plain-text 401 as a credentials hint, not a parse error', async () => {
const deps = fakeDeps([{ status: 401, text: 'Unauthorized' }]);
expect(await agentLs(deps)).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/401.*password/);
});
});
describe('agent send', () => {
it('refuses to type into its own composer', async () => {
const deps = fakeDeps([]);
expect(await agentSend(deps, { id: SELF.slice(0, 8), text: 'hi', enter: true })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('fire-and-forget: input + \\r, a fixed clientId per caller, seq from the clock', async () => {
const deps = fakeDeps([ok({ delivered: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'say DONE_1', enter: true })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/input`,
body: { input: 'say DONE_1\r', useMux: true, clientId: 'codeman-agent-cli-058ee7b5', seq: 1_700_000_000_000 },
});
expect(deps.calls[0].body).not.toHaveProperty('wait');
});
it('--wait passes the signal list and timeout through and maps the result to an exit code', async () => {
const stop = fakeDeps([ok({ delivered: true, wait: { signal: 'stop', timedOut: false } })]);
expect(await agentSend(stop, { id: OTHER, text: 'go', enter: true, wait: 'stop,exit', timeoutMs: 5000 })).toBe(
EXIT.ok
);
expect(stop.calls[0].body).toMatchObject({ wait: 'stop,exit', waitTimeout: 5000 });
const timeout = fakeDeps([ok({ delivered: true, wait: { timedOut: true, timeoutMs: 5000 } })]);
expect(await agentSend(timeout, { id: OTHER, text: 'go', enter: true, wait: true, timeoutMs: 5000 })).toBe(
EXIT.timeout
);
const dead = fakeDeps([ok({ delivered: true, wait: { signal: 'exit' } })]);
expect(await agentSend(dead, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
});
it('delivered:false without duplicate is "the bytes went nowhere": exit 3, never a ✓', async () => {
const deps = fakeDeps([ok({ delivered: false, duplicate: false, wait: { ended: true, signal: null } })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true, wait: true })).toBe(EXIT.dead);
expect(deps.out.join('')).not.toMatch(/delivered to/);
expect(deps.err.join('')).toMatch(/not delivered.*restart/);
});
it('fire-and-forget says "accepted", not "delivered" (the route answers before the write)', async () => {
const deps = fakeDeps([ok({})]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
expect(deps.out.join('')).toMatch(/accepted for/);
expect(deps.out.join('')).not.toMatch(/delivered to/);
});
it('a sleeping remote host: `buffered` gets its own line and exit 0, never "accepted"', async () => {
const deps = fakeDeps([ok({ buffered: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.ok);
expect(deps.out.join('')).toMatch(/buffered for .*asleep/);
expect(deps.out.join('')).not.toMatch(/accepted for/);
});
it('`dropped` (over the wake buffer cap) is a failure: exit 1, nothing claims success', async () => {
const deps = fakeDeps([ok({ buffered: true, dropped: true })]);
expect(await agentSend(deps, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/dropped: .*nothing will be typed/);
expect(deps.out).toEqual([]);
const json = fakeDeps([ok({ buffered: true, dropped: true })], true);
expect(await agentSend(json, { id: OTHER, text: 'go', enter: true })).toBe(EXIT.error);
expect(JSON.parse(json.out.join(''))).toEqual({ buffered: true, dropped: true });
});
it('reports a tagged duplicate instead of claiming delivery', async () => {
const deps = fakeDeps([ok({ delivered: false, duplicate: true })]);
await agentSend(deps, { id: OTHER, text: 'go', enter: true });
expect(deps.out.join('')).toMatch(/duplicate/);
});
});
describe('agent wait', () => {
it('--until goes to /wait and a 400 for a hook-less mode is passed through, not papered over', async () => {
const deps = fakeDeps([apiError(400, 'INVALID_INPUT', 'until=stop is not available for mode opencode')]);
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.calls[0]).toMatchObject({
method: 'GET',
path: `/api/v1/sessions/${OTHER}/wait`,
query: { until: 'stop', timeout: 1000 },
});
expect(deps.err.join('')).toMatch(/INVALID_INPUT.*opencode/);
});
it('--match goes to /wait-output with from=buffer by default', async () => {
const deps = fakeDeps([ok({ wait: { matched: true, match: 'DONE_1', snippet: 'DONE_1' } })]);
expect(await agentWait(deps, { id: OTHER, match: 'DONE_1', timeoutMs: 1000 })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
path: `/api/v1/sessions/${OTHER}/wait-output`,
query: { match: 'DONE_1', from: 'buffer', timeout: 1000 },
});
});
it('refuses --until together with --match', async () => {
const deps = fakeDeps([]);
expect(await agentWait(deps, { id: OTHER, until: 'idle', match: 'x', timeoutMs: 1000 })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('a wait that ended without an answer is reported as dead, not as `signal: null`', async () => {
const deps = fakeDeps([ok({ wait: { ended: true, signal: null, timedOut: false } })]);
expect(await agentWait(deps, { id: OTHER, until: 'stop', timeoutMs: 1000 })).toBe(EXIT.dead);
expect(deps.out.join('')).toMatch(/went away/);
expect(deps.out.join('')).not.toMatch(/signal: null/);
});
it('exit codes: matched/signal 0, timeout 2, exit 3', () => {
expect(waitExitCode({ signal: 'stop' })).toBe(EXIT.ok);
expect(waitExitCode({ matched: true })).toBe(EXIT.ok);
expect(waitExitCode({ matched: false, timedOut: true })).toBe(EXIT.timeout);
expect(waitExitCode({ timedOut: true })).toBe(EXIT.timeout);
expect(waitExitCode({ signal: 'exit' })).toBe(EXIT.dead);
// A worker that dies during --until stop: the registry only satisfies waiters that
// listed `exit`, then cancels the rest → ended:true, signal:null. Never "done".
expect(waitExitCode({ ended: true, signal: null, timedOut: false })).toBe(EXIT.dead);
expect(waitExitCode({ ended: true, matched: false, timedOut: false })).toBe(EXIT.dead);
expect(waitExitCode(undefined)).toBe(EXIT.error);
});
});
describe('agent read', () => {
it('defaults to last-response and prints the text', async () => {
const deps = fakeDeps([ok({ text: 'the answer', timestamp: 't' })]);
expect(await agentRead(deps, { id: OTHER })).toBe(EXIT.ok);
expect(deps.calls[0].path).toBe(`/api/v1/sessions/${OTHER}/last-response`);
expect(deps.out).toEqual(['the answer']);
});
it('says why an empty transcript is empty instead of printing nothing', async () => {
const deps = fakeDeps([ok({ text: '' })]);
await agentRead(deps, { id: OTHER });
expect(deps.err.join('')).toMatch(/nothing answered yet.*--tail 3000/);
});
it('--tail fetches the terminal and strips ANSI', async () => {
const deps = fakeDeps([ok({ terminalBuffer: '\u001b]0;w1 title\u0007\u001b[32m❯\u001b[0m ready \u001b(B' })]);
expect(await agentRead(deps, { id: OTHER, tail: 500 })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ path: `/api/v1/sessions/${OTHER}/terminal`, query: { tail: 500 } });
expect(deps.out).toEqual(['❯ ready ']);
});
it('--full prints every message with its role', async () => {
const deps = fakeDeps([
ok({
text: 'b',
messages: [
{ role: 'user', text: 'a' },
{ role: 'assistant', text: 'b' },
],
}),
]);
await agentRead(deps, { id: OTHER, full: true });
expect(deps.calls[0].query).toMatchObject({ context: 'full' });
expect(deps.out.map(stripAnsi)).toEqual(['user: a', 'assistant: b']);
});
});
describe('agent interrupt', () => {
it('sends the bare ESC body under its own clientId, never to itself', async () => {
const deps = fakeDeps([ok({ delivered: true })]);
expect(await agentInterrupt(deps, { id: OTHER })).toBe(EXIT.ok);
expect(deps.calls[0].body).toEqual({
input: '\u001b',
useMux: true,
clientId: 'codeman-agent-cli-058ee7b5-interrupt',
seq: 1_700_000_000_000,
});
const self = fakeDeps([]);
expect(await agentInterrupt(self, { id: SELF })).toBe(EXIT.refused);
expect(self.calls).toEqual([]);
});
});
describe('send takes the prompt as ONE argument', () => {
it('refuses several words, which an unquoted multi-line $(…) becomes after word splitting', () => {
expect(sendPromptFromArgs(['review src/, then say DONE'])).toEqual({ text: 'review src/, then say DONE' });
expect(sendPromptFromArgs(['line', 'one', 'line', 'two'])).toMatchObject({
error: expect.stringMatching(/ONE argument, got 4/),
});
// A leading "-" is commander's option syntax, so the hint names the escape.
expect(sendPromptFromArgs(['a', 'b'])).toMatchObject({ error: expect.stringContaining('send <id> -- "- fix') });
});
});
describe('agent spawn: a worker that dies during the readiness wait', () => {
it('is exit 3 with its own line, not the composer-timeout hint', async () => {
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { ended: true, matched: false } })]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.dead);
expect(deps.err.join('')).toMatch(/exited during the readiness wait/);
expect(deps.err.join('')).not.toMatch(/composer not seen/);
});
});
describe('agent wait: a timeout is an answer, not a failure', () => {
it('prints a neutral line and exits 2', async () => {
const deps = fakeDeps([ok({ wait: { timedOut: true, timeoutMs: 1000 } })]);
expect(await agentWait(deps, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 })).toBe(EXIT.timeout);
const line = deps.out.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
expect(line).toBe('timed out after 1000 ms (exit 2)');
expect(deps.err).toEqual([]);
});
});
describe('agent spawn', () => {
it('quick-starts with lineage and waits for the claude composer', async () => {
const deps = fakeDeps([
ok({ sessionId: OTHER, caseName: 'scratch-1', casePath: '/x' }),
ok({ wait: { matched: true } }),
]);
expect(await agentSpawn(deps, { caseName: 'scratch-1', mode: 'claude', ready: true, timeoutMs: 2000 })).toBe(
EXIT.ok
);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: '/api/v1/quick-start',
body: { caseName: 'scratch-1', mode: 'claude', parentSessionId: SELF },
});
expect(deps.calls[1]).toMatchObject({
path: `/api/v1/sessions/${OTHER}/wait-output`,
query: { match: 'shift+tab', from: 'buffer', timeout: 2000 },
});
expect(deps.out).toEqual([OTHER]); // stdout is the id ALONE, so `SID=$(…)` works; prose goes to stderr
expect(deps.err.join('')).toMatch(/spawned .*composer up/s);
});
it('labels the case as agent scratch on the spawn request, and on no other request', async () => {
// The label drives a recursive-delete affordance in the Add Case UI: it may only ride
// the request that can CREATE a case directory (quick-start), never anything else.
const spawn = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: true } })]);
await agentSpawn(spawn, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 });
expect(spawn.calls[0].headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-agent-cli' });
expect(spawn.calls[1].headers?.['X-Codeman-Agent-Origin']).toBeUndefined(); // the readiness wait
const everyOther = fakeDeps((o) =>
o.path === '/api/v1/sessions' ? ok([]) : ok({ wait: { signal: 'stop' }, text: '' })
);
await agentLs(everyOther);
await agentSend(everyOther, { id: OTHER, text: 'hi', enter: true });
await agentWait(everyOther, { id: OTHER, until: 'stop', from: 'buffer', timeoutMs: 1000 });
await agentRead(everyOther, { id: OTHER });
await agentInterrupt(everyOther, { id: OTHER });
await agentRm(everyOther, { id: OTHER });
expect(everyOther.calls.length).toBeGreaterThan(5);
for (const call of everyOther.calls) expect(call.headers?.['X-Codeman-Agent-Origin'], call.path).toBeUndefined();
expect(baseHeaders(ctx())).not.toHaveProperty('X-Codeman-Agent-Origin');
});
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
expect(composerReadyMark('claude')).toBe('shift+tab');
expect(composerReadyMark('deepseek')).toBe('❯');
expect(composerReadyMark('pi')).toBeUndefined();
expect(composerReadyMark('no-such-cli')).toBeUndefined();
});
it('a composer that never shows up is exit 2 and the session is left for inspection, not deleted', async () => {
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), ok({ wait: { matched: false, timedOut: true } })]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.timeout);
expect(deps.calls.map((c) => c.method)).toEqual(['POST', 'GET']);
expect(deps.err.join('')).toMatch(/startup dialog/);
});
it('a mode without a readiness mark returns after the create, and --no-ready skips the wait everywhere', async () => {
const pi = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
expect(await agentSpawn(pi, { caseName: 'c', mode: 'pi', ready: true, timeoutMs: 1000 })).toBe(EXIT.ok);
expect(pi.calls).toHaveLength(1);
const noReady = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' })]);
expect(await agentSpawn(noReady, { caseName: 'c', mode: 'claude', ready: false, timeoutMs: 1000 })).toBe(EXIT.ok);
expect(noReady.calls).toHaveLength(1);
});
it('a failed readiness call reports its own reason instead of the trust-dialog hint', async () => {
const deps = fakeDeps([ok({ sessionId: OTHER, caseName: 'c' }), apiError(429, 'RATE_LIMITED', 'waiter pool full')]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/readiness check failed: RATE_LIMITED/);
expect(deps.err.join('')).not.toMatch(/trust dialog/);
expect(deps.out).toEqual([OTHER]); // the session exists; the id is still handed back
});
it('a failed quick-start is terminal: the error code is shown and nothing else is called', async () => {
const deps = fakeDeps([apiError(409, 'SESSION_BUSY', 'session cap reached')]);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.calls).toHaveLength(1);
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
});
});
describe('session id prefixes', () => {
const THIRD = '94990c6d-ffff-4000-8000-000000000000';
const list = ok([{ id: SELF }, { id: OTHER }, { id: THIRD }]);
it('a full id goes straight to the route, no list call', async () => {
const deps = fakeDeps([ok({ text: 'x' })]);
await agentRead(deps, { id: OTHER });
expect(deps.calls.map((c) => c.path)).toEqual([`/api/v1/sessions/${OTHER}/last-response`]);
});
it('a unique prefix (what `ls` prints) resolves through the list — the routes 404 on prefixes', async () => {
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ text: 'x' })]);
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.ok);
expect(deps.calls.map((c) => c.path)).toEqual(['/api/v1/sessions', `/api/v1/sessions/${OTHER}/last-response`]);
});
it('an ambiguous prefix refuses instead of picking one', async () => {
const deps = fakeDeps([list]);
expect(await agentRead(deps, { id: '94990c6d' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/ambiguous.*94990c6d-e461.*94990c6d-ffff/);
expect(deps.calls).toHaveLength(1);
});
it('an unknown prefix names the problem', async () => {
const deps = fakeDeps([list]);
expect(await agentRm(deps, { id: 'deadbeef' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/no session starts with "deadbeef"/);
expect(deps.calls.map((c) => c.method)).toEqual(['GET']);
});
it('a prefix shorter than 8 characters refuses (exit 4) before any request, on every verb', async () => {
expect(MIN_ID_PREFIX_LENGTH).toBe(8);
// The maintainer's repro: `rm 9` with one other session starting with 9 deleted it.
const rm = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({})]);
expect(await agentRm(rm, { id: '9' })).toBe(EXIT.refused);
expect(rm.calls).toEqual([]);
expect(rm.err.join('')).toMatch(/"9" is shorter than 8 characters.*8-character id `agent ls` prints/);
const short = OTHER.slice(0, 7);
const verbs: Array<[string, (deps: AgentDeps) => Promise<number>]> = [
['send', (d) => agentSend(d, { id: short, text: 'go', enter: true })],
['wait', (d) => agentWait(d, { id: short, until: 'idle', timeoutMs: 1000 })],
['read', (d) => agentRead(d, { id: short })],
['interrupt', (d) => agentInterrupt(d, { id: short })],
['rm', (d) => agentRm(d, { id: short })],
];
for (const [verb, call] of verbs) {
const deps = fakeDeps([ok([{ id: SELF }, { id: OTHER }]), ok({ delivered: true })]);
expect(await call(deps), verb).toBe(EXIT.refused);
expect(deps.calls, verb).toEqual([]);
}
});
it('rm runs the self guard before the list and again on the resolved id', async () => {
const first = fakeDeps([ok([{ id: SELF }])]);
expect(await agentRm(first, { id: SELF.slice(0, 8) })).toBe(EXIT.refused);
expect(first.calls).toEqual([]); // refused before any request
const resolved = fakeDeps([ok([{ id: SELF }, { id: OTHER }])]);
expect(await agentRm(resolved, { id: 'deadbeef' })).toBe(EXIT.error); // nothing to delete
expect(resolved.calls.map((c) => c.method)).toEqual(['GET']);
});
});
describe('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
it('--match says the prompt must not contain the marker verbatim, and how to split it', () => {
const match = sub('wait').options.find((o) => o.long === '--match')!;
expect(match.description).toMatch(/never put the marker verbatim in the prompt/);
expect(match.description).toMatch(/WORKDONE followed by _4711.*WORKDONE_4711/);
});
it('send names the -- escape for a prompt that starts with "-"', () => {
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
});
it('rm does not claim a lineage check it does not make', () => {
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
});
});
describe('option parsing', () => {
it('positive integers only, the server rejects the rest', () => {
expect(parsePositiveInt(undefined, 60000)).toBe(60000);
expect(parsePositiveInt('1500', 1)).toBe(1500);
for (const bad of ['0', '-1', '1.5', '30s', '']) expect(() => parsePositiveInt(bad, 1)).toThrow(/positive integer/);
});
it('describeFailure prefers the envelope and falls back to the status line', () => {
expect(describeFailure(apiError(404, 'NOT_FOUND', 'no such session'))).toBe(
'NOT_FOUND: no such session (HTTP 404)'
);
expect(describeFailure({ status: 403, text: 'Forbidden: host not allowed' })).toBe(
'HTTP 403 Forbidden: host not allowed'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// The real transport against a local server
// ─────────────────────────────────────────────────────────────────────────────
describe('httpRequest', () => {
let server: http.Server;
let apiUrl: string;
const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() });
if (req.url?.startsWith('/plain')) {
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
return;
}
res
.writeHead(200, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: true, data: { echo: true } }));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address() as { port: number };
apiUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('carries lineage headers, Basic auth and a urlencoded query (the + in shift+tab survives)', async () => {
const res = await httpRequest(ctx({ apiUrl, auth: { username: 'joe', password: 'pw' } }), {
method: 'GET',
path: '/api/v1/sessions/x/wait-output',
query: { match: 'shift+tab', from: 'buffer', timeout: 1000, nocase: undefined },
});
expect(res.status).toBe(200);
expect(res.json).toEqual({ success: true, data: { echo: true } });
const last = seen.at(-1)!;
expect(last.url).toBe('/api/v1/sessions/x/wait-output?match=shift%2Btab&from=buffer&timeout=1000');
expect(last.headers['x-codeman-parent-session']).toBe(SELF);
expect(last.headers['x-codeman-agent-origin']).toBeUndefined(); // only spawn's quick-start carries it
expect(last.headers.authorization).toBe(`Basic ${Buffer.from('joe:pw').toString('base64')}`);
});
it('posts JSON bodies with a length, and no auth header when the server is open', async () => {
await httpRequest(ctx({ apiUrl }), {
method: 'POST',
path: '/api/v1/sessions/x/input',
body: { input: 'hi\r', seq: 1 },
});
const last = seen.at(-1)!;
expect(last.method).toBe('POST');
expect(JSON.parse(last.body)).toEqual({ input: 'hi\r', seq: 1 });
expect(last.headers['content-type']).toBe('application/json');
expect(last.headers.authorization).toBeUndefined();
});
it('keeps a plain-text body when the answer is not JSON', async () => {
const res = await httpRequest(ctx({ apiUrl }), { method: 'GET', path: '/plain' });
expect(res.status).toBe(401);
expect(res.json).toBeUndefined();
expect(res.text).toBe('Unauthorized');
});
});
+2
View File
@@ -33,6 +33,7 @@ function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: C
const TOP_LEVEL: Record<string, string[]> = {
attach: [],
skill: [],
agent: [],
session: ['s'],
task: ['t'],
ralph: ['r'],
@@ -53,6 +54,7 @@ const SUBCOMMANDS: Record<string, Record<string, string[]>> = {
task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] },
ralph: { start: [], stop: [], status: [] },
skill: { install: [], uninstall: [] },
agent: { ls: ['list'], spawn: [], send: [], wait: [], read: [], interrupt: [], rm: [] },
service: { install: [], uninstall: [], status: [] },
users: { add: [], passwd: [], list: ['ls'], rm: [] },
};
+61
View File
@@ -0,0 +1,61 @@
/**
* @fileoverview The one credential reader `codeman attach`, `codeman tui` and
* `codeman agent` share: env first, the data dir's `.env` as the fallback.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { basicAuthHeader, readCodemanCredentials, readCodemanEnvFile } from '../src/codeman-credentials.js';
describe('readCodemanCredentials', () => {
let dir: string;
const saved = { user: process.env.CODEMAN_USERNAME, pass: process.env.CODEMAN_PASSWORD };
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-cred-'));
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_PASSWORD;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (saved.user === undefined) delete process.env.CODEMAN_USERNAME;
else process.env.CODEMAN_USERNAME = saved.user;
if (saved.pass === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = saved.pass;
});
it('falls back to the .env file, quotes and an export prefix stripped, default user admin', () => {
const env = join(dir, '.env');
writeFileSync(env, '# a comment\nnot an assignment\nexport CODEMAN_PASSWORD="hunter2"\n');
expect(readCodemanCredentials(env)).toEqual({ username: 'admin', password: 'hunter2' });
});
it('prefers the environment over the file', () => {
const env = join(dir, '.env');
writeFileSync(env, 'CODEMAN_USERNAME=file\nCODEMAN_PASSWORD=file-pass\n');
process.env.CODEMAN_USERNAME = 'envuser';
process.env.CODEMAN_PASSWORD = 'env-pass';
expect(readCodemanCredentials(env)).toEqual({ username: 'envuser', password: 'env-pass' });
});
it('an absent file means no password, and no header to send', () => {
const creds = readCodemanCredentials(join(dir, 'missing'));
expect(creds).toEqual({ username: 'admin' });
expect(basicAuthHeader(creds)).toBeUndefined();
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
});
it('takes an explicit environment, field by field', () => {
const env = join(dir, '.env');
writeFileSync(env, 'CODEMAN_USERNAME=joe\n');
expect(readCodemanCredentials(env, { CODEMAN_PASSWORD: 'pw' })).toEqual({ username: 'joe', password: 'pw' });
});
it('builds a Basic header from a password', () => {
expect(basicAuthHeader({ username: 'joe', password: 'pw' })).toBe(
`Basic ${Buffer.from('joe:pw').toString('base64')}`
);
});
});
+2 -2
View File
@@ -1342,8 +1342,8 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
expect(modal.classList.contains('active')).toBe(true);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
expect(message).toContain('16,384');
expect(message).toContain('40,000');
expect(message).toContain((16384).toLocaleString()); // the modal formats for the user's locale
expect(message).toContain((40000).toLocaleString());
expect(message).toMatch(/llama-swap/i);
expect(message).toMatch(/fit-ctx/i);
+19 -6
View File
@@ -6,6 +6,7 @@
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import {
buildBaseUrl,
buildStatusUrl,
@@ -16,7 +17,17 @@ import {
probeServer,
} from '../src/daemon-control.js';
const PORT = 3216;
/**
* A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
* moment of use, unlike "the server's port + 1", which anything may hold.
*/
async function closedPort(): Promise<number> {
const probe = http.createServer();
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
const { port: free } = probe.address() as AddressInfo;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return free;
}
describe('buildWebArgs', () => {
it('always passes host and port through explicitly', () => {
@@ -143,6 +154,7 @@ describe('isProcessAlive', () => {
describe('probeServer', () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer((req, res) => {
@@ -157,7 +169,8 @@ describe('probeServer', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
});
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as AddressInfo).port;
});
afterAll(async () => {
@@ -165,23 +178,23 @@ describe('probeServer', () => {
});
it('reports up and reads the version back', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
const result = await probeServer(`http://127.0.0.1:${port}/api/status`);
expect(result.up).toBe(true);
expect(result.version).toBe('9.9.9');
});
it('counts a 401 as up, because auth being active proves a server is there', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
const result = await probeServer(`http://127.0.0.1:${port}/unauthorized`);
expect(result.up).toBe(true);
});
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
const result = await probeServer(`http://127.0.0.1:${port}/foreign`);
expect(result.up).toBe(false);
});
it('reports down when nothing is listening', async () => {
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
const result = await probeServer(`http://127.0.0.1:${await closedPort()}/api/status`, 1000);
expect(result.up).toBe(false);
});
+7 -4
View File
@@ -16,6 +16,7 @@
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
import { dirname } from 'node:path';
import {
@@ -25,8 +26,6 @@ import {
DEEPSEEK_STATE_TO_HOOK_EVENT,
} from '../src/deepseek-status-shim.js';
const PORT = 3251;
describe('DeepSeek status shim: provisioning', () => {
beforeEach(() => {
resetDeepSeekStatusShimForTest();
@@ -73,6 +72,7 @@ describe('DeepSeek status shim: provisioning', () => {
describe('DeepSeek status shim: the supervisor contract', () => {
let server: Server | undefined;
let port: number;
const received: Array<{ body: unknown; secret: string | undefined }> = [];
let status = 200;
@@ -96,7 +96,10 @@ describe('DeepSeek status shim: the supervisor contract', () => {
res.end('{}');
});
});
server.listen(PORT, '127.0.0.1', resolve);
server.listen(0, '127.0.0.1', () => {
port = (server!.address() as AddressInfo).port;
resolve();
});
});
beforeAll(() => listen());
@@ -120,7 +123,7 @@ describe('DeepSeek status shim: the supervisor contract', () => {
const child = spawn(process.execPath, [path, ...args], {
env: {
...process.env,
CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
CODEMAN_SESSION_ID: 'sess-from-env',
...env,
},
+2 -4
View File
@@ -3,8 +3,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3196;
const REPORT = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
@@ -46,13 +44,13 @@ describe('Diagnostics panel in a real browser', () => {
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
// A controlling service worker can swallow requests before page.route() sees them, letting the
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
}, 90000);
+6 -7
View File
@@ -4,7 +4,6 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3110;
const CASES_DIR = join(homedir(), 'codeman-cases');
describe('Edge Cases and Error Handling', () => {
@@ -13,9 +12,9 @@ describe('Edge Cases and Error Handling', () => {
const createdCases: string[] = [];
beforeAll(async () => {
server = new WebServer(TEST_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterEach(() => {
@@ -263,9 +262,9 @@ describe('Concurrent Session Handling', () => {
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT + 1, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT + 1}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -360,9 +359,9 @@ describe('API Request Validation', () => {
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT + 2, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT + 2}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
+123 -7
View File
@@ -31,6 +31,7 @@ const SURFACES = [
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
] as const;
/**
@@ -40,6 +41,11 @@ const SURFACES = [
*/
const CSS_LESS_STYLES = new Set(['off', 'fly']);
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
function isCssLess(attr: string, key: string): boolean {
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
}
function styleKeys(arrayName: string): string[] {
const start = animSource.indexOf(`const ${arrayName} = [`);
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
@@ -47,12 +53,21 @@ function styleKeys(arrayName: string): string[] {
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
}
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
function themes(): Theme[] {
const start = animSource.indexOf('const ANIM_THEMES = [');
const body = animSource.slice(start, animSource.indexOf('];', start));
return [
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
const parsed = [
...body.matchAll(
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
),
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
// Every theme entry must parse: a theme missing a surface (or a regex that
// drifted from the source) would otherwise pass the checks below vacuously.
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
expect(parsed.length).toBeGreaterThan(0);
return parsed;
}
/**
@@ -82,7 +97,7 @@ describe('entrance animation styles', () => {
describe(`${surface.attr} surface`, () => {
it('backs every style with a rule that names a keyframe block that exists', () => {
for (const key of styleKeys(surface.array)) {
if (CSS_LESS_STYLES.has(key)) {
if (isCssLess(surface.attr, key)) {
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
continue;
}
@@ -99,8 +114,16 @@ describe('entrance animation styles', () => {
});
}
it('ships the blur style on all four surfaces', () => {
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
/**
* Tiles are the exception: six frames animate at once, so their styles stay
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
*/
it('ships the blur style on the four single-element surfaces', () => {
for (const surface of SURFACES) {
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
else expect(styleKeys(surface.array)).toContain('blur');
}
});
it('gives every theme an <option> and only styles that exist', () => {
@@ -173,3 +196,96 @@ describe('entrance animation styles', () => {
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
});
describe('tile grid entrance styles', () => {
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
const tileNames = (key: string, scope: RegExp) =>
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
.filter((rule) => scope.test(rule[1]))
.flatMap((rule) =>
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
name: m[1],
onPseudo: rule[1].includes('::before'),
}))
);
/**
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
* A tile fits once at its final size (#464); a box-model property here would
* resize its PTY mid-animation, and a filter on six live terminals at once
* is the frame-time cost the pane's `blur` takes for one.
*/
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
if (key === 'off') continue;
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
for (const { name, onPseudo } of names) {
const body = keyframeBody(name);
expect(body, `@keyframes ${name} missing`).not.toBeNull();
if (onPseudo) continue; // a wash over the tile, no layout of its own
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
}
}
}
});
/**
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
* animationend, and the still copy goes on its last tile's `tile-leave*`:
* a keyframe named otherwise would leave the class on (or the copy up)
* until a backstop timer.
*/
it('names every frame keyframe for the events tile-grid.js listens for', () => {
for (const key of styleKeys('TILE_ANIM_STYLES')) {
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
}
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
}
}
});
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
expect(timed.length).toBeGreaterThan(0);
for (const key of timed) {
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
}
expect(timed).not.toContain('settle');
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
// The legacy theme (the default) leaves the grid's own motion untouched.
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
});
/**
* App Settings → Appearance → Tile Animations: one option per style, wired
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
* own `settle` first and named as the off default.
*/
it('lists every tile style in the Tile Animations setting, off by default', () => {
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
expect(values[0]).toBe('settle');
expect(select).toContain('<option value="settle">Off (default)</option>');
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
// Nothing new for an install that never picks it: no saved key means settle.
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
});
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
it('plays every terminal pane style on a tile screen too', () => {
for (const key of styleKeys('TERM_ANIM_STYLES')) {
if (CSS_LESS_STYLES.has(key)) continue;
// The body itself, not only its ::before wash.
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
}
});
});
+4 -4
View File
@@ -4,7 +4,7 @@
* Tests that file paths displayed in terminal output are clickable
* and open the log viewer window correctly.
*
* Port allocation: 3154 (see CLAUDE.md test port table)
* Port allocation: ephemeral port
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -14,8 +14,7 @@ import { writeFileSync, mkdirSync, rmSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const TEST_PORT = 3154;
const baseUrl = `http://localhost:${TEST_PORT}`;
let baseUrl: string;
const BROWSER_TIMEOUT = 30000;
// Helper to run agent-browser commands
@@ -97,8 +96,9 @@ describe('File Link Click Tests', () => {
testLogFile = join(testDir, 'test.log');
writeFileSync(testLogFile, '=== Test Log Started ===\n');
server = new WebServer(TEST_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
await new Promise((r) => setTimeout(r, 1000));
// Test if browser is available
@@ -1,182 +0,0 @@
/**
* @fileoverview A tab switch to a pane with no tmux scrollback takes the small capture.
*
* After the first select of a page, a tab switch to a non-shell session used to
* fetch a 1 MiB tail of the server's byte stream. For a fullscreen claude pane
* (alternate screen, `#{history_size}` 0) that tail is old repaints of one frame:
* measured on live panes it cost 250-1070 ms on the server and 70-510 ms to
* parse, and an idle tab parsed it twice (the cached copy, then the fresh one,
* which never matched). The `full=1` capture of such a pane is the visible
* frame, a few KB, so `selectSession` now takes it whenever the session's last
* capture reported `paneHistoryLines: 0`, and goes back to the tail as soon as a
* capture reports scrollback again.
*
* Drives the real client in chromium against a testMode server, with the
* terminal route stubbed (the real one needs live tmux to report a hollow pane).
*
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*
* Run: npm run test:browser -- test/fullscreen-tab-switch-capture.browser.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true); // testMode
await server.start();
browser = await chromium.launch({ headless: true });
}, 60_000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 30_000);
/** What each session's pane reports, set by the test as the "pane" changes. */
type PaneHistory = Record<string, number | undefined>;
/**
* Serve every terminal fetch from a stub and log which shape was asked for.
* `source` follows the real route: `full=1` answers `mux-full-history`, anything
* else `mux-visible`. No capture geometry, so the geometry retry stays out of it.
*/
async function stubTerminal(page: Page, history: PaneHistory, log: string[]) {
await page.route('**/api/sessions/*/terminal*', async (route) => {
const url = new URL(route.request().url());
const id = url.pathname.split('/')[3];
const full = url.searchParams.get('full') === '1';
log.push(`${id}:${full ? 'full' : 'tail'}`);
const frame = `frame of ${id}\r\n`;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
terminalBuffer: full ? frame : `${'old repaint\r\n'.repeat(50)}${frame}`,
status: 'idle',
fullSize: full ? frame.length : 5 * 1024 * 1024,
retainedBytes: full ? frame.length : 1024 * 1024,
truncated: !full,
truncationReason: full ? null : 'tail',
source: full ? 'mux-full-history' : 'mux-visible',
paneHistoryLines: history[id],
},
}),
});
});
}
async function openApp(page: Page): Promise<void> {
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
timeout: 30_000,
});
}
async function createSession(page: Page, name: string): Promise<string> {
return page.evaluate(async (n) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: n }),
});
const body = await res.json();
return body.data?.session?.id ?? body.data?.id ?? body.id;
}, name);
}
async function select(page: Page, sessionId: string): Promise<void> {
await page.evaluate(async (sid) => {
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
await app.selectSession(sid);
}, sessionId);
}
async function deleteSession(page: Page, sessionId: string): Promise<void> {
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
}
describe('tab switch to a pane that keeps no scrollback', () => {
it('takes the small full capture on every switch, and the tail again once the pane keeps history', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
const page = await context.newPage();
await openApp(page);
const hollow = await createSession(page, 'fullscreen-claude');
const inline = await createSession(page, 'inline-claude');
const history: PaneHistory = { [hollow]: 0, [inline]: 40_000 };
const log: string[] = [];
await stubTerminal(page, history, log);
// First select of each on this page: the canonical full replay, as before.
await select(page, hollow);
await select(page, inline);
expect(log).toEqual([`${hollow}:full`, `${inline}:full`]);
// Switching back: the hollow pane takes the frame, the inline one the tail.
log.length = 0;
await select(page, hollow);
await select(page, inline);
await select(page, hollow);
expect(log).toEqual([`${hollow}:full`, `${inline}:tail`, `${hollow}:full`]);
// The pane starts keeping history (claude switched to its inline view). The
// switch that learns it is still a full capture, which is the canonical
// load anyway; the one after goes back to the bounded tail.
history[hollow] = 1200;
log.length = 0;
await select(page, inline);
await select(page, hollow);
await select(page, inline);
await select(page, hollow);
expect(log).toEqual([`${inline}:tail`, `${hollow}:full`, `${inline}:tail`, `${hollow}:tail`]);
// Unknown is never treated as empty: a response without the field (an
// older server, a byte-history fallback) keeps the tail.
history[hollow] = 0;
log.length = 0;
await select(page, inline);
await select(page, hollow); // learns 0 from this tail response
history[hollow] = undefined;
await select(page, inline);
await select(page, hollow); // last report was 0: frame, which reports nothing
await select(page, inline);
await select(page, hollow); // forgot it: tail
expect(log).toEqual([
`${inline}:tail`,
`${hollow}:tail`,
`${inline}:tail`,
`${hollow}:full`,
`${inline}:tail`,
`${hollow}:tail`,
]);
// What the hollow switch actually paints: the frame, without the repaints.
history[hollow] = 0;
await select(page, inline);
await select(page, hollow); // learns 0
await select(page, inline);
await select(page, hollow);
const screen = await page.evaluate(() => {
const t = (window as unknown as { app: { terminal: any } }).app.terminal;
const lines: string[] = [];
for (let i = 0; i < t.buffer.active.length; i++) lines.push(t.buffer.active.getLine(i)?.translateToString(true));
return lines.join('\n');
});
expect(screen).toContain(`frame of ${hollow}`);
expect(screen).not.toContain('old repaint');
await deleteSession(page, hollow);
await deleteSession(page, inline);
await context.close();
}, 90_000);
});
+8
View File
@@ -239,6 +239,14 @@ describe('gitNonInteractiveEnv', () => {
it("does not override a user's own GIT_SSH_COMMAND", () => {
expect(gitNonInteractiveEnv({ GIT_SSH_COMMAND: 'ssh -F /custom' }).GIT_SSH_COMMAND).toBe('ssh -F /custom');
});
// Issue #568. CI runs in an English locale, so the real-git tests below cannot
// catch a revert: only this assertion fails if the pin is dropped.
it("pins git's messages to English over the host's locale, since classifyGitFailure() matches English stderr", () => {
const env = gitNonInteractiveEnv({ LC_ALL: 'de_DE.UTF-8', LANG: 'de_DE.UTF-8', LANGUAGE: 'de' });
expect(env.LC_ALL).toBe('C');
expect(env.LANG).toBe('C');
});
});
describe('parseLsRemoteOutput', () => {
+2 -3
View File
@@ -7,7 +7,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3192;
const ENV = {
...process.env,
GIT_AUTHOR_NAME: 'T',
@@ -85,7 +84,7 @@ describe('Git status indicator in a real browser', () => {
write('a.txt', '2\n');
write('new file.txt', 'n\n');
server = new WebServer(PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
@@ -96,7 +95,7 @@ describe('Git status indicator in a real browser', () => {
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
repoSession = await createSession(repo);
plainSession = await createSession(plain);
-278
View File
@@ -1,278 +0,0 @@
/**
* @fileoverview The partial-history notice waits until the user reaches for history.
*
* Every tab switch after the first replays a 1 MiB TAIL of the session's byte
* stream, which is truncated for any session that has run for a while, so the
* notice ("Showing the most recent 1.0 MB of this session. 4.8 MB more may still
* be retained.") covered the top rows on nearly every switch. Its × only lasted
* until the next switch. Now:
* - it appears only once a scroll gesture reaches the top of the browser's
* buffer, after the history pull that gesture starts has settled;
* - a scroll back down to live output retires it, and so does a tab switch;
* - a dismissal sticks for that session until the page reloads.
*
* Runs the REAL methods (app.js banner + state, terminal-ui.js scroll hook,
* constants.js notice decision) in a `vm` against a stub DOM, the same way
* shell-scroll-history-pull.test.ts does (no jsdom on this box).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const APP = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
interface FakeEl {
tagName: string;
hidden: boolean;
className: string;
type: string;
disabled: boolean;
children: FakeEl[];
attrs: Record<string, string>;
onclick: null | (() => void);
textContent: string;
appendChild(child: FakeEl): void;
setAttribute(name: string, value: string): void;
}
function fakeEl(tagName: string): FakeEl {
let text = '';
const el: FakeEl = {
tagName,
hidden: false,
className: '',
type: '',
disabled: false,
children: [],
attrs: {},
onclick: null,
get textContent() {
return text + el.children.map((c) => c.textContent).join('');
},
set textContent(value: string) {
text = value;
el.children = [];
},
appendChild(child) {
el.children.push(child);
},
setAttribute(name, value) {
el.attrs[name] = value;
},
};
return el;
}
/** Real terminal-ui.js mixin, for `_maybeLoadMoreHistoryOnScroll` / `isTerminalAtBottom`. */
function loadTerminalMixin(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function makeApp() {
const bar = fakeEl('div');
bar.hidden = true;
const document = {
getElementById: (id: string) => (id === 'historyTruncationBar' ? bar : null),
createElement: (tag: string) => fakeEl(tag),
};
const methods = [
'_setHistoryTruncation',
'_clearHistoryTruncation',
'_setHistoryNoticeRevealed',
'_renderHistoryTruncationBanner',
]
.map((m) => methodSource(APP, m))
.join(',\n');
const context = vm.createContext({ document, console, window: {}, navigator: { userAgent: 'test' } });
const appMethods = vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}
;({ ${methods} })`,
context,
{ filename: 'app-methods.js' }
) as Record<string, (...args: unknown[]) => unknown>;
const mixin = loadTerminalMixin();
const buffer = { viewportY: 500, baseY: 500 };
let resolvePull: (() => void) | null = null;
const app = {
activeSessionId: 's1' as string | null,
terminal: { buffer: { active: buffer } },
// Each gesture's pull is held open until the test settles it.
_maybeRefetchFullHistory: vi.fn(
() =>
new Promise<void>((res) => {
resolvePull = res;
})
),
...appMethods,
_maybeLoadMoreHistoryOnScroll: mixin._maybeLoadMoreHistoryOnScroll,
isTerminalAtBottom: mixin.isTerminalAtBottom,
} as Record<string, any>;
const settle = async () => {
resolvePull?.();
resolvePull = null;
for (let i = 0; i < 5; i++) await Promise.resolve();
};
const scrollTo = async (viewportY: number) => {
const lines = viewportY - buffer.viewportY;
buffer.viewportY = viewportY;
app._maybeLoadMoreHistoryOnScroll(lines);
await settle();
};
return { app, bar, buffer, scrollTo, settle };
}
// What a tab switch's tail replay reports for a session with real scrollback.
const TAIL = {
truncated: true,
truncationReason: 'tail',
source: 'mux-visible',
fullSize: 5 * 1024 * 1024,
retainedBytes: 1024 * 1024,
paneHistoryLines: 40000,
};
const loadButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-load');
const dismissButton = (bar: FakeEl) => bar.children.find((c) => c.className === 'history-trunc-dismiss');
describe('partial-history notice: lazy reveal', () => {
it('stays hidden after a truncated tab-switch replay', () => {
const { app, bar } = makeApp();
app._setHistoryTruncation('s1', TAIL);
expect(bar.hidden).toBe(true);
});
it('appears once a scroll reaches the top, after the pull that gesture started', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1);
// Still pulling: no notice describing the state the pull is about to replace.
expect(bar.hidden).toBe(true);
await settle();
expect(bar.hidden).toBe(false);
expect(bar.textContent).toContain('40,000 lines of scrollback are retained.');
expect(loadButton(bar)?.textContent).toBe('Load full history');
});
it('shows what the pull left: nothing, when the pull brought everything back', async () => {
const { app, bar, buffer } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._maybeRefetchFullHistory.mockImplementation(async () => {
app._setHistoryTruncation('s1', { truncated: false, source: 'mux-full-history', paneHistoryLines: 40000 });
});
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('does not appear on the way up, only at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(200);
expect(app._maybeRefetchFullHistory).not.toHaveBeenCalled();
expect(bar.hidden).toBe(true);
});
it('goes away once the user scrolls back down to live output', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(false);
await scrollTo(300); // still reading history
expect(bar.hidden).toBe(false);
await scrollTo(500); // back at the bottom
expect(bar.hidden).toBe(true);
});
it('never appears for a pane with no scrollback (fullscreen CLI), even at the top', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', { ...TAIL, paneHistoryLines: 0 });
await scrollTo(0);
expect(app._historyNoticeRevealedFor).toBe('s1');
expect(bar.hidden).toBe(true);
});
it('is not revealed for a tab the user switched to while the pull ran', async () => {
const { app, bar, buffer, settle } = makeApp();
app._setHistoryTruncation('s1', TAIL);
app._setHistoryTruncation('s2', TAIL);
buffer.viewportY = 0;
app._maybeLoadMoreHistoryOnScroll(-500);
app.activeSessionId = 's2';
await settle();
expect(app._historyNoticeRevealedFor ?? null).toBe(null);
expect(bar.hidden).toBe(true);
});
it('keeps a dismissal for that session, but only that session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
expect(bar.hidden).toBe(true);
// A new replay and another trip to the top do not bring it back.
await scrollTo(500);
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
expect(bar.hidden).toBe(true);
// Another session still gets its notice.
app.activeSessionId = 's2';
app._setHistoryTruncation('s2', TAIL);
await scrollTo(500);
await scrollTo(0);
expect(bar.hidden).toBe(false);
});
it('forgets the dismissal with the session', async () => {
const { app, bar, scrollTo } = makeApp();
app._setHistoryTruncation('s1', TAIL);
await scrollTo(0);
dismissButton(bar)!.onclick!();
app._clearHistoryTruncation('s1');
expect(app._historyNoticeDismissed.has('s1')).toBe(false);
});
});
describe('partial-history notice: a tab switch retires it (static guard)', () => {
it('selectSession clears the reveal before repainting the banner', () => {
const body = methodSource(APP, 'selectSession');
const reset = body.indexOf('this._historyNoticeRevealedFor = null;');
const render = body.indexOf('this._renderHistoryTruncationBanner();');
expect(reset).toBeGreaterThan(-1);
expect(render).toBeGreaterThan(reset);
});
});
+1 -71
View File
@@ -45,11 +45,6 @@ describe('formatHistoryBytes', () => {
expect(formatHistoryBytes(3 * 1024 * 1024)).toBe('3.0 MB');
});
it('never prints "1024 KB" for a tail cut back to a line boundary just under 1 MiB', () => {
expect(formatHistoryBytes(1048351)).toBe('1.0 MB');
expect(formatHistoryBytes(1023 * 1024)).toBe('1023 KB');
});
it('survives junk input rather than printing NaN into the UI', () => {
expect(formatHistoryBytes(-5)).toBe('less than 1 KB');
expect(formatHistoryBytes(NaN as unknown as number)).toBe('less than 1 KB');
@@ -119,65 +114,6 @@ describe('computeHistoryTruncationNotice (issue #258)', () => {
});
});
describe('computeHistoryTruncationNotice: what a pull can really return (paneHistoryLines)', () => {
const { computeHistoryTruncationNotice } = loadHelpers();
// The tab-switch tail of a fullscreen claude pane, as measured on prod: the
// server cut a 5.8 MB byte stream to 1 MB, and tmux held 0 scrollback rows.
const fullscreenTail = {
truncated: true,
reason: 'tail',
source: 'mux-visible',
fullSize: 6158853,
retainedBytes: 1048351,
};
it('says nothing for a pane that keeps no scrollback, however much the byte stream lost', () => {
// The dropped bytes were old repaints of one frame, and `full=1` returns
// only the visible frame for such a pane, so the button could only ever end
// in the downgrade refusal. That is the banner that showed on every switch.
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0 });
expect(notice).toEqual({ visible: false, message: '', canLoadMore: false });
});
it('stays silent for such a pane in the exhausted and at-ceiling states too', () => {
expect(computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 0, exhausted: true }).visible).toBe(
false
);
expect(
computeHistoryTruncationNotice({
...fullscreenTail,
source: 'mux-full-history',
reason: 'capped',
paneHistoryLines: 0,
}).visible
).toBe(false);
});
it('names the scrollback lines a pull can load instead of the byte gap', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, source: 'history', paneHistoryLines: 48210 });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toBe(
'Showing the most recent 1.0 MB of this session. 48,210 lines of scrollback are retained.'
);
expect(notice.message).not.toContain('4.9 MB');
});
it('uses the singular for one line', () => {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines: 1 });
expect(notice.message).toContain('1 line of scrollback is retained.');
});
it('keeps the byte wording when the server did not report the pane (older server, byte-history fallback)', () => {
for (const paneHistoryLines of [undefined, null, NaN]) {
const notice = computeHistoryTruncationNotice({ ...fullscreenTail, paneHistoryLines });
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
expect(notice.message).toContain('more may still be retained');
}
});
});
describe('the in-terminal truncation line is gone (static guard)', () => {
it('no longer writes the notice into terminal output', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
@@ -188,13 +124,7 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
// A shell never takes the full capture on a tab switch. A TUI takes it on its
// first select per page, and on every select while its pane keeps no tmux
// scrollback (behaviour pinned in fullscreen-tab-switch-capture.browser.test.ts).
expect(app).toContain(
"session?.mode !== 'shell' && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId))"
);
expect(app).toContain('const paneKeepsNoHistory = this._paneHistoryLines?.get(sessionId) === 0;');
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
// Every terminal capture now goes through _fetchTerminalCapture, which adds
+5 -7
View File
@@ -782,21 +782,19 @@ describe('subagent stop guard helper', () => {
});
// ========== Hook Event API Integration Tests ==========
// Port 3130 reserved for hooks integration tests
// Hooks integration tests use an ephemeral port
import { WebServer } from '../src/web/server.js';
const TEST_PORT = 3130;
describe('Hook Event API', () => {
let server: WebServer;
let baseUrl: string;
let testSessionId: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
// Create a test session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
@@ -976,9 +974,9 @@ describe('Hook Data Sanitization', () => {
let testSessionId: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT + 1, false, true); // Port 3131
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT + 1}`;
baseUrl = `http://localhost:${server.boundPort}`;
// Create a test session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
+3 -4
View File
@@ -11,15 +11,14 @@ import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
const PORT = 3168;
describe('Stable HTTP contract (live server)', () => {
let server: WebServer;
const base = `http://localhost:${PORT}`;
let base: string;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
base = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
+12
View File
@@ -238,4 +238,16 @@ describe('image upload insertion policy', () => {
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
});
it('shows the server reason in the toast when an upload fails', async () => {
const app = loadImageInputApp();
const reason = 'Prompt uploads are not supported for remote (SSH) sessions';
app._uploadPasteImage = vi.fn(async () => {
throw new Error(reason);
});
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
expect(app.showToast).toHaveBeenCalledWith(`1 failed: ${reason}`, 'error');
});
});
+9
View File
@@ -31,6 +31,7 @@ vi.mock('node:fs', async (importOriginal) => {
});
import { ImageWatcher } from '../src/image-watcher.js';
import { watch } from 'chokidar';
import { statSync } from 'node:fs';
describe('ImageWatcher', () => {
@@ -92,6 +93,14 @@ describe('ImageWatcher', () => {
expect(watcher.getWatchedSessions()).toHaveLength(1);
});
it("ignores Codeman's own upload folders, so a pdf the user handed over is not a detected artifact", () => {
watcher.watchSession('session-1', '/home/user/project');
const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }];
expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true);
expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true);
expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false);
});
it('should replace watcher when working directory changes', () => {
watcher.watchSession('session-1', '/home/user/project-a');
watcher.watchSession('session-1', '/home/user/project-b');
+10 -14
View File
@@ -13,18 +13,14 @@
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
*
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
* (per MEMORY.md, ports 3150+ for tests)
* Ports: ephemeral, for this and the two server-backed describes below
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3164;
const ORDERING_PORT = 3165;
const LONG_PREFIX_PORT = 3192;
const BASE_URL = `http://localhost:${PORT}`;
let baseUrl: string;
describe('Inline rename input', () => {
let server: WebServer;
@@ -32,11 +28,12 @@ describe('Inline rename input', () => {
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode = true
server = new WebServer(0, false, true); // testMode = true
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
// Wait for app.js to expose window.app and finish constructor init.
await page.waitForFunction(
() =>
@@ -667,11 +664,11 @@ describe('Inline rename write ordering', () => {
type Pending = { body: string; resolve: (response: Response) => void };
beforeAll(async () => {
server = new WebServer(ORDERING_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
typeof (window as { app?: unknown }).app !== 'undefined' &&
@@ -1010,14 +1007,13 @@ describe('Inline rename write ordering', () => {
describe('Vertical rail rename editor with a long prefix', () => {
let server: WebServer;
let browser: Browser;
const port = LONG_PREFIX_PORT;
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
let sessionId = '';
beforeAll(async () => {
server = new WebServer(port, false, true);
server = new WebServer(0, false, true);
await server.start();
const res = await fetch(`http://localhost:${port}/api/sessions`, {
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: NAME, mode: 'shell' }),
@@ -1048,7 +1044,7 @@ describe('Vertical rail rename editor with a long prefix', () => {
settings
);
const page = await context.newPage();
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
// One #sessionTabs list, moved into the rail or the sidebar by the layout.
const row = page.locator(`#sessionTabs .session-tab[data-id="${sessionId}"]`);
await row.waitFor({ state: 'visible', timeout: 15000 });
+4 -5
View File
@@ -4,7 +4,6 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const TEST_PORT = 3115;
const CASES_DIR = join(homedir(), 'codeman-cases');
/**
@@ -18,9 +17,9 @@ describe('Integration Flows', () => {
const createdSessions: string[] = [];
beforeAll(async () => {
server = new WebServer(TEST_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterEach(() => {
@@ -282,9 +281,9 @@ describe('SSE Event Flow', () => {
const createdSessions: string[] = [];
beforeAll(async () => {
server = new WebServer(TEST_PORT + 1, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT + 1}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {

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