mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge remote-tracking branch 'origin/master' into relprep/pr-614
This commit is contained in:
commit
0d42fdc0cf
635 files changed
+97889
-3794
No files matched your search
@@ -0,0 +1,95 @@
|
||||
# CLAUDE.md (src/web: frontend invariants)
|
||||
|
||||
> Loaded on demand: Claude Code reads this when a file under `src/web/` is opened with the Read tool, and the root `CLAUDE.md` tells sessions to Read it before editing `src/web/public/`. These rules were moved here from the root file verbatim. **All paths below are relative to the repo root**, like the root file (only the markdown link targets are relative to this file, so they resolve on GitHub). The root `CLAUDE.md` still holds every cross-cutting rule (session safety, testing, security, server-side patterns).
|
||||
>
|
||||
> **This file is in `.prettierignore`** for the same reason as the root one: Prettier escapes underscores in glob-heavy paths.
|
||||
|
||||
## Frontend key patterns
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. ⚠️ The close is OPTIMISTIC: the tab goes and the next one is selected BEFORE the DELETE is sent (upserts skip ids in `_closingSessions`), and only a delete the server refused (checked with a GET) puts the row back; never await the server before hiding the tab. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](../../docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](../../docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](../../docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. SuchLine truncated
|
||||
|
||||
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. ⚠️ A sideways drag belongs to the session swipe (`SwipeHandler`, mobile-handlers.js): the touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls, `x` pans a terminal wider than its box, `swipe` otherwise) and publishes it as `app._terminalGestureAxis`; a `swipe` drag neither scrolls nor ends as a tap on the session being left until it passes the swipe's own vertical drift limit (`SwipeHandler.maxVerticalDrift`), where it is handed back to the scroll as `y` (a scroll that opens on a thumb arc), and the swipe stands aside for `x`/`y`, a touch selection, a second finger and surfaces with controls of their own (`IGNORE_SELECTOR`; the phone overview and welcome screen are deliberately not listed). A swipe is 80 px within 500 ms with at most 50 px of vertical drift at any point; a touch starting outside the terminal (the home screen) resets the published axis, and a lift the terminal consumed (`defaultPrevented`, its selection gesture) is never a swipe (`test/session-swipe-gesture.test.ts`). Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
|
||||
|
||||
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](../../docs/architecture-invariants.md#auto-copy-copy-on-select)
|
||||
|
||||
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
|
||||
|
||||
**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A detached session window (`/session/:id`) is rendered WITHOUT `__codemanCliAvailable`, so it cannot select a CLI notice: while any notice is pending (every CLI the file names counted as installed), it shows and records nothing and leaves the update toast and the notice to the dashboard, or it would record the version and the dashboard would show the notice as a separate card; with none pending it shows the plain toast as before. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (small amber text and the primary button use `--notice-ink`, which the light skins darken a step because their `--yellow` is under 4.5:1 for that text on white; the primary button's label is `--bg-card`), are capped to the room below the stack's top and scroll inside themselves (the container is fixed and cannot scroll, so the accent bar is a background layer, never a positioned child), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. A closing card is inert and its buttons do nothing during the fade; links open with `noopener,noreferrer` and the https rule is checked again at the button (`CodemanSecurityNotices.urlOk`). The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
|
||||
|
||||
## Frontend modules and UI subsystems
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module (`create()` returns null unless `matchMedia('(pointer: coarse)')` matches or the user agent is Android, so a desktop dead key is not forwarded twice, #716, while an Android device whose mouse, trackpad cover or stylus Chromium reports as the fine primary pointer keeps the soft-keyboard recovery): the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. With the opt-in tab "+" on (`showTabNewButton`, `html.tabs-show-new`) a third icon joins them and the reserve grows to 68px, still under the 80px `max-width`. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||
|
||||
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
|
||||
|
||||
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip; once it wraps on desktop it is L-shaped via `_syncTabStripCorner()` (the toolbar sits over its top-right corner and later rows run the full width under it, a strip already past its max-height keeps the plain wrap): ⚠️ `.tabs-corner` comes off before the wrap is measured, and the strip is `display: block` in that state, so flex-only rules do not apply to it there (`test/tab-strip-corner.browser.test.ts`). Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
|
||||
|
||||
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles)
|
||||
|
||||
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](../../docs/architecture-invariants.md#phone-overview-home-screen)
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](../../docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `keyboardHoldsRows`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
|
||||
|
||||
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](../../docs/architecture-invariants.md#theme-skins)
|
||||
|
||||
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](../../docs/architecture-invariants.md#foldable-settings-identity)
|
||||
|
||||
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices)
|
||||
|
||||
**Rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): while the keyboard is up, an eligible session keeps its PTY rows (`app._keyboardHeldRows`, captured by `KeyboardHandler` on the show transition and read through `getTerminalDimensions()`) and `.xterm-screen` is translated up by `--kb-clip` (`_applyKeyboardClip()`, `keyboardClipPx()`), with the local-echo overlay bounded through `setRowLimit()`. ⚠️ Eligibility is `_keyboardHoldEligible()` only (the published `window.__codemanKeyboardHoldModes`, never an id; never with `cliMouseTracking`, remote or docker). ⚠️ Every path that can change eligibility calls `_reconcileKeyboardHold()` (tab switch, `session:updated`, reconnect, settings save), keyboard hide and a shape change release it (a shape change also arms the viewport settle, the only refit while the keyboard is up), and nothing re-holds with the keyboard already up. → [architecture-invariants#pty-and-browser-terminal-geometry](../../docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](../../docs/architecture-invariants.md#websocket-output-gap-reconcile)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ An accessory key that keeps the keyboard up (`ACCESSORY_REFOCUS_ACTIONS`, keyboard-accessory.js) cancels its `mousedown` default, so the press does not blur the terminal; the click-time `terminal.focus()` stays as the fallback. Letting the tap blur and refocusing afterwards made every such key start a dismissal, a blur/focus pair per tap. A first tap on clear/compact only arms the confirm and is treated the same (`test/accessory-keeps-terminal-focus.test.ts`). ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](../../docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
|
||||
|
||||
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important`: that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey. Press feedback on the phone bars sidesteps this: the `:active` rules for `.btn-toolbar`, the mobile icon buttons and `.accessory-btn` (mobile.css) use `transform` and `filter` only, which nothing on these buttons sets at a higher specificity, so one rule reads the same on every skin; a background `:active` lost to the skin block on all but og, and an inset `box-shadow` lost to the skin hover rules, which is why neither is used. The light skins darken instead of brightening, and `prefers-reduced-motion` drops the scale.
|
||||
|
||||
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
|
||||
|
||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. ⚠️ The watchdog cannot see an attempt that never OPENS (no response headers: no `open`, no `error`, never `connected`), so each attempt is bounded by `SSE_CONNECT_TIMEOUT_MS` (15s, `CodemanSseStale.CONNECT_TIMEOUT_MS`) and then retried through `_handleSseConnectionFailure()`, the same backoff `onerror` uses. Its timer is cleared at the top of `connectSSE()` BEFORE the offline early return, on `open`, and in `_handleSseConnectionFailure()`; the timer and the handler both ignore an `EventSource` that has been replaced. The window `offline` handler leaves it running on purpose (`navigator.onLine` can report offline on a working network). Tests: `test/sse-connect-timeout.test.ts`. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
|
||||
|
||||
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview Opt-in snapshot of the agent inbox, so pending mail between agents
|
||||
* survives a server restart (the agents themselves keep running in tmux across it).
|
||||
*
|
||||
* Off by default: an install that never turns on `agentInboxPersistEnabled` writes
|
||||
* nothing new to disk. The rules (discussion #558):
|
||||
* - Off means no file. Switching it off deletes the snapshot; the in-memory inboxes
|
||||
* stay as they are.
|
||||
* - Its own file under `dataPath()`, written 0600 through tmp + rename like
|
||||
* `intents.json`, since messages carry the same kind of text prompts do. It is never
|
||||
* read by `/api/search` and `sensitive-path.ts` keeps the file viewer off it.
|
||||
* - Writes are debounced (a burst of posts is one write) and flushed on shutdown.
|
||||
* - Boot pruning is NOT done here: the server calls `agentInbox.pruneExcept()` only
|
||||
* after the session restore succeeded, so a failed restore leaves the mail alone.
|
||||
*
|
||||
* @module web/agent-inbox-persistence
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { agentInbox, type AgentInbox } from './agent-inbox.js';
|
||||
|
||||
/** A burst of posts (a coordinator fanning out to five workers) becomes one write. */
|
||||
const WRITE_DEBOUNCE_MS = 250;
|
||||
|
||||
export class AgentInboxPersistence {
|
||||
private enabled = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly inbox: AgentInbox = agentInbox,
|
||||
/** Resolved per call: test HOMEs change between files. */
|
||||
private readonly filePath: () => string = () => dataPath('agent-inbox.json'),
|
||||
private readonly debounceMs: number = WRITE_DEBOUNCE_MS
|
||||
) {
|
||||
this.inbox.onChange = () => this.schedule();
|
||||
}
|
||||
|
||||
get isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot: apply the setting and, when it is on, load the snapshot into the store.
|
||||
* Returns how many messages were restored. A missing or unreadable file is an
|
||||
* empty inbox, never a failed boot.
|
||||
*/
|
||||
load(enabled: boolean): number {
|
||||
this.enabled = enabled;
|
||||
if (!enabled) {
|
||||
// A file left from an earlier "on" is removed, so off really means no file.
|
||||
this.removeFile();
|
||||
return 0;
|
||||
}
|
||||
const path = this.filePath();
|
||||
if (!existsSync(path)) return 0;
|
||||
try {
|
||||
return this.inbox.restore(JSON.parse(readFileSync(path, 'utf-8')));
|
||||
} catch (err) {
|
||||
console.warn(`[AgentInbox] Failed to load ${path}, starting empty:`, err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The setting changed. On: write the current inboxes now. Off: drop any pending
|
||||
* write and delete the snapshot; memory is untouched.
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (enabled === this.enabled) return;
|
||||
this.enabled = enabled;
|
||||
if (enabled) {
|
||||
this.writeNow();
|
||||
} else {
|
||||
this.cancel();
|
||||
this.removeFile();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shutdown: write a pending change now instead of losing it with the timer. */
|
||||
flush(): void {
|
||||
if (!this.timer) return;
|
||||
this.cancel();
|
||||
this.writeNow();
|
||||
}
|
||||
|
||||
/** Test hook: drop a pending write and detach from the store. */
|
||||
dispose(): void {
|
||||
this.cancel();
|
||||
if (this.inbox.onChange) this.inbox.onChange = undefined;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.enabled || this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.writeNow();
|
||||
}, this.debounceMs);
|
||||
// A pending write must not hold the process open; shutdown flushes it explicitly.
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private cancel(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
private writeNow(): void {
|
||||
if (!this.enabled) return;
|
||||
const path = this.filePath();
|
||||
const tmpPath = `${path}.tmp`;
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// 0600: agent messages can carry anything a prompt can, secrets included.
|
||||
// `mode` applies only when the file is created, and the rename makes every
|
||||
// write a fresh file, so the final file is always 0600.
|
||||
rmSync(tmpPath, { force: true });
|
||||
writeFileSync(tmpPath, JSON.stringify(this.inbox.snapshot()), { mode: 0o600 });
|
||||
renameSync(tmpPath, path);
|
||||
} catch (err) {
|
||||
console.warn(`[AgentInbox] Failed to persist ${path}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFile(): void {
|
||||
const path = this.filePath();
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
rmSync(`${path}.tmp`, { force: true });
|
||||
} catch (err) {
|
||||
console.warn(`[AgentInbox] Failed to remove ${path}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide instance wired to `agentInbox`; the server and the settings route use it. */
|
||||
export const agentInboxPersistence = new AgentInboxPersistence();
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* @fileoverview Agent inbox: a per-session mailbox other agents (or the user) post to,
|
||||
* and the receiving agent reads when it chooses to.
|
||||
*
|
||||
* The other agent-to-agent channel, `POST /api/sessions/:id/input`, types into the
|
||||
* receiver's composer. That is right for a prompt and wrong for a message: every
|
||||
* delivery starts or interrupts a turn, the receiver cannot defer it, and it is one
|
||||
* line only. The inbox stores instead of typing. Nothing here ever reaches a pane.
|
||||
*
|
||||
* Invariants (each pinned in `test/agent-inbox.test.ts`):
|
||||
* - **Read until ack.** A read hands out every pending message and removes nothing.
|
||||
* A message goes only when the receiver acknowledges its id. "Read and dropped"
|
||||
* and "done" must not look the same to the sender (discussion #558, the 09-23 case).
|
||||
* - **Seen is the receiver's alone.** `read(…, { receiver: true })` marks what it
|
||||
* handed out as seen; any other read is a peek and changes nothing. The route
|
||||
* decides who the receiver is; this module only takes the flag.
|
||||
* - **Bounded, and full refuses.** `MAX_MESSAGES_PER_INBOX`, `MAX_MESSAGE_BYTES` and
|
||||
* `MAX_INBOX_BYTES`. A post that would cross any of them is refused so the sender
|
||||
* learns. The oldest message is never dropped to make room.
|
||||
* - **TTL from the message's own timestamp** (`MESSAGE_TTL_MS`). A restore keeps
|
||||
* `createdAt`, so a restart does not restart the clock. Expiry is lazy: every
|
||||
* accessor sweeps the inbox it touches, `summary()` and `snapshot()` sweep all.
|
||||
* - **No IO, no `Session` import.** Persistence (`agent-inbox-persistence.ts`) hooks
|
||||
* `onChange`; long-poll waiters live in `session-wait-registry.ts`.
|
||||
*
|
||||
* @consumedby web/routes/inbox-routes, web/agent-inbox-persistence, web/server
|
||||
* @module web/agent-inbox
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// ─── Bounds ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Messages one inbox holds before posts are refused. */
|
||||
export const MAX_MESSAGES_PER_INBOX = 200;
|
||||
/** UTF-8 bytes per message. A bigger payload belongs in the workspace; post its path. */
|
||||
export const MAX_MESSAGE_BYTES = 16_384;
|
||||
/** UTF-8 bytes of message text one inbox holds before posts are refused. */
|
||||
export const MAX_INBOX_BYTES = 512 * 1024;
|
||||
/** A message older than this is gone, read or not (same span as the approvals inbox). */
|
||||
export const MESSAGE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A message as it is stored (and persisted). */
|
||||
interface StoredMessage {
|
||||
id: string;
|
||||
/** The posting session's full id, or null when no session could be resolved (the user). */
|
||||
from: string | null;
|
||||
text: string;
|
||||
/** Epoch ms. The TTL counts from here. */
|
||||
createdAt: number;
|
||||
/** Epoch ms of the first receiver read that handed this message out; null while unseen. */
|
||||
seenAt: number | null;
|
||||
}
|
||||
|
||||
/** A message as a read hands it out. */
|
||||
export interface InboxMessage {
|
||||
id: string;
|
||||
from: string | null;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
/** True when a receiver read had handed this message out BEFORE this read. */
|
||||
seen: boolean;
|
||||
}
|
||||
|
||||
export interface InboxReadResult {
|
||||
/** Every pending message, oldest first: unseen and seen-but-unacked alike. */
|
||||
messages: InboxMessage[];
|
||||
pending: number;
|
||||
/** Unseen after this read (0 after a receiver read; unchanged by a peek). */
|
||||
unseen: number;
|
||||
}
|
||||
|
||||
export type PostResult =
|
||||
| { ok: true; message: InboxMessage; pending: number; unseen: number }
|
||||
| { ok: false; reason: 'empty' | 'message-too-large' | 'inbox-full-count' | 'inbox-full-bytes' };
|
||||
|
||||
/** What `summary()` reports for one session. Counts and times only, never text. */
|
||||
export interface InboxCounts {
|
||||
pending: number;
|
||||
unseen: number;
|
||||
/** `createdAt` of the oldest unseen message, null when nothing is unseen. */
|
||||
oldestUnseenAt: number | null;
|
||||
}
|
||||
|
||||
/** On-disk shape. `version` lets a later format refuse an older file instead of misreading it. */
|
||||
export interface InboxSnapshot {
|
||||
version: 1;
|
||||
inboxes: Record<string, StoredMessage[]>;
|
||||
}
|
||||
|
||||
// ─── Store ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export class AgentInbox {
|
||||
private readonly inboxes = new Map<string, StoredMessage[]>();
|
||||
|
||||
/** Any mutation (post, seen, ack, expiry, drop, prune, restore excluded). Persistence hooks this. */
|
||||
onChange: (() => void) | undefined;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
/**
|
||||
* Store a message for `sessionId`. `from` is the RESOLVED sender (the route's job),
|
||||
* never a client-supplied label.
|
||||
*/
|
||||
post(sessionId: string, from: string | null, text: string): PostResult {
|
||||
if (text.length === 0) return { ok: false, reason: 'empty' };
|
||||
const bytes = Buffer.byteLength(text, 'utf8');
|
||||
if (bytes > MAX_MESSAGE_BYTES) return { ok: false, reason: 'message-too-large' };
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length >= MAX_MESSAGES_PER_INBOX) return { ok: false, reason: 'inbox-full-count' };
|
||||
if (inboxBytes(queue) + bytes > MAX_INBOX_BYTES) return { ok: false, reason: 'inbox-full-bytes' };
|
||||
const stored: StoredMessage = { id: randomUUID(), from, text, createdAt: this.now(), seenAt: null };
|
||||
queue.push(stored);
|
||||
this.inboxes.set(sessionId, queue);
|
||||
this.onChange?.();
|
||||
return { ok: true, message: view(stored), pending: queue.length, unseen: countUnseen(queue) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pending message, oldest first. Removes nothing. With `receiver`, the
|
||||
* messages handed out are marked seen (each keeps its first `seenAt`); without it
|
||||
* this is a peek and changes nothing at all.
|
||||
*/
|
||||
read(sessionId: string, options: { receiver: boolean }): InboxReadResult {
|
||||
const queue = this.live(sessionId);
|
||||
const messages = queue.map(view);
|
||||
if (options.receiver) {
|
||||
let marked = false;
|
||||
const at = this.now();
|
||||
for (const m of queue) {
|
||||
if (m.seenAt !== null) continue;
|
||||
m.seenAt = at;
|
||||
marked = true;
|
||||
}
|
||||
if (marked) this.onChange?.();
|
||||
}
|
||||
return { messages, pending: queue.length, unseen: countUnseen(queue) };
|
||||
}
|
||||
|
||||
/** Messages no receiver read has handed out yet. A `?wait=` read parks while this is 0. */
|
||||
unseenCount(sessionId: string): number {
|
||||
return countUnseen(this.live(sessionId));
|
||||
}
|
||||
|
||||
pendingCount(sessionId: string): number {
|
||||
return this.live(sessionId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given messages. Idempotent: an id that is unknown or already gone is
|
||||
* not an error, so a retry after a lost response succeeds. Returns how many went.
|
||||
*/
|
||||
ack(sessionId: string, ids: readonly string[]): number {
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length === 0 || ids.length === 0) return 0;
|
||||
const drop = new Set(ids);
|
||||
const kept = queue.filter((m) => !drop.has(m.id));
|
||||
const removed = queue.length - kept.length;
|
||||
if (removed === 0) return 0;
|
||||
this.store(sessionId, kept);
|
||||
this.onChange?.();
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Per-session counts for every inbox that holds anything. Never blocks. */
|
||||
summary(): Map<string, InboxCounts> {
|
||||
const out = new Map<string, InboxCounts>();
|
||||
for (const sessionId of [...this.inboxes.keys()]) {
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length === 0) continue;
|
||||
const unseen = queue.filter((m) => m.seenAt === null);
|
||||
out.set(sessionId, {
|
||||
pending: queue.length,
|
||||
unseen: unseen.length,
|
||||
oldestUnseenAt: unseen.length > 0 ? unseen[0].createdAt : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The session was DELETED: its mail goes with it. Not called on detach or shutdown. */
|
||||
drop(sessionId: string): void {
|
||||
if (this.inboxes.delete(sessionId)) this.onChange?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time sweep, run only after the session restore succeeded: discard inboxes
|
||||
* whose session did not come back. Every route needs a live session, so an orphan
|
||||
* could otherwise never be removed. Returns how many inboxes were dropped.
|
||||
*/
|
||||
pruneExcept(keep: Iterable<string>): number {
|
||||
const alive = new Set(keep);
|
||||
let dropped = 0;
|
||||
for (const sessionId of [...this.inboxes.keys()]) {
|
||||
if (alive.has(sessionId)) continue;
|
||||
this.inboxes.delete(sessionId);
|
||||
dropped++;
|
||||
}
|
||||
if (dropped > 0) this.onChange?.();
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/** Whole-store snapshot for persistence, expired messages already swept. */
|
||||
snapshot(): InboxSnapshot {
|
||||
const inboxes: Record<string, StoredMessage[]> = {};
|
||||
for (const sessionId of [...this.inboxes.keys()]) {
|
||||
const queue = this.live(sessionId);
|
||||
if (queue.length > 0) inboxes[sessionId] = queue.map((m) => ({ ...m }));
|
||||
}
|
||||
return { version: 1, inboxes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the store from a snapshot (server boot). Malformed input is skipped entry
|
||||
* by entry rather than thrown: a corrupt file must not stop the server. Expired
|
||||
* messages are not restored, and the caps hold for what is. Does not fire
|
||||
* `onChange`: writing back what was just read would be a no-op at best.
|
||||
*/
|
||||
restore(snapshot: unknown): number {
|
||||
const data = snapshot as Partial<InboxSnapshot> | null;
|
||||
this.inboxes.clear();
|
||||
if (!data || data.version !== 1 || typeof data.inboxes !== 'object' || data.inboxes === null) return 0;
|
||||
const cutoff = this.now() - MESSAGE_TTL_MS;
|
||||
let restored = 0;
|
||||
for (const [sessionId, raw] of Object.entries(data.inboxes)) {
|
||||
if (!Array.isArray(raw)) continue;
|
||||
const queue: StoredMessage[] = [];
|
||||
let bytes = 0;
|
||||
// Oldest first whatever order the file has, so the caps keep the oldest mail,
|
||||
// which is the rule for posts too.
|
||||
const entries = raw
|
||||
.filter((entry): entry is StoredMessage => isStoredMessage(entry) && entry.createdAt > cutoff)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const entry of entries) {
|
||||
const size = Buffer.byteLength(entry.text, 'utf8');
|
||||
if (queue.length >= MAX_MESSAGES_PER_INBOX || bytes + size > MAX_INBOX_BYTES) break;
|
||||
queue.push({ ...entry });
|
||||
bytes += size;
|
||||
}
|
||||
if (queue.length === 0) continue;
|
||||
this.inboxes.set(sessionId, queue);
|
||||
restored += queue.length;
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** Test hook: back to an empty store with no listener. */
|
||||
resetForTests(): void {
|
||||
this.inboxes.clear();
|
||||
this.onChange = undefined;
|
||||
}
|
||||
|
||||
/** The inbox with expired messages swept out (fires `onChange` when it swept any). */
|
||||
private live(sessionId: string): StoredMessage[] {
|
||||
const queue = this.inboxes.get(sessionId);
|
||||
if (!queue) return [];
|
||||
const cutoff = this.now() - MESSAGE_TTL_MS;
|
||||
// Every message, not only queue[0]: a clock stepping back can put an older
|
||||
// createdAt behind a newer one. At most 200 entries, so the scan is cheap.
|
||||
if (!queue.some((m) => m.createdAt <= cutoff)) return queue;
|
||||
const kept = queue.filter((m) => m.createdAt > cutoff);
|
||||
this.store(sessionId, kept);
|
||||
this.onChange?.();
|
||||
return kept;
|
||||
}
|
||||
|
||||
private store(sessionId: string, queue: StoredMessage[]): void {
|
||||
if (queue.length === 0) this.inboxes.delete(sessionId);
|
||||
else this.inboxes.set(sessionId, queue);
|
||||
}
|
||||
}
|
||||
|
||||
function view(m: StoredMessage): InboxMessage {
|
||||
return { id: m.id, from: m.from, text: m.text, createdAt: m.createdAt, seen: m.seenAt !== null };
|
||||
}
|
||||
|
||||
function countUnseen(queue: readonly StoredMessage[]): number {
|
||||
let n = 0;
|
||||
for (const m of queue) if (m.seenAt === null) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function inboxBytes(queue: readonly StoredMessage[]): number {
|
||||
let total = 0;
|
||||
for (const m of queue) total += Buffer.byteLength(m.text, 'utf8');
|
||||
return total;
|
||||
}
|
||||
|
||||
function isStoredMessage(value: unknown): value is StoredMessage {
|
||||
const m = value as Partial<StoredMessage> | null;
|
||||
return (
|
||||
!!m &&
|
||||
typeof m.id === 'string' &&
|
||||
(m.from === null || typeof m.from === 'string') &&
|
||||
typeof m.text === 'string' &&
|
||||
m.text.length > 0 &&
|
||||
Buffer.byteLength(m.text, 'utf8') <= MAX_MESSAGE_BYTES &&
|
||||
typeof m.createdAt === 'number' &&
|
||||
Number.isFinite(m.createdAt) &&
|
||||
(m.seenAt === null || typeof m.seenAt === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
/** Process-wide store used by the routes and the server wiring. Tests construct their own. */
|
||||
export const agentInbox = new AgentInbox();
|
||||
@@ -15,7 +15,9 @@
|
||||
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
|
||||
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
|
||||
* - Items are in-memory only. A server restart drops them; the next prompt
|
||||
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
|
||||
* re-fires the hook. Answerable items are Claude's alone; the DeepSeek status
|
||||
* bridge and the screen-read dialogs of a hook-less CLI (codex, see
|
||||
* `web/screen-blocked.ts`) open alert-only items (`answerInTerminal`).
|
||||
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
|
||||
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
|
||||
* failed write unless a newer prompt arrived meanwhile.
|
||||
@@ -24,7 +26,8 @@
|
||||
*
|
||||
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
|
||||
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
||||
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
||||
* web/session-listener-wiring (working/exit resolution), web/screen-blocked (screen-read
|
||||
* dialogs), web/server (emit callbacks + stop)
|
||||
*
|
||||
* @module web/approval-inbox
|
||||
*/
|
||||
@@ -85,6 +88,13 @@ export interface ApprovalItem {
|
||||
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
|
||||
*/
|
||||
options?: ApprovalOption[];
|
||||
/**
|
||||
* When true the dialog belongs to a third-party TUI whose keystrokes Codeman
|
||||
* cannot verify (today: DeepSeek Harness / `hooks: 'supervised'`). The item
|
||||
* is an ALERT only: the answer route refuses keystrokes, push omits Approve/
|
||||
* Deny actions, and the UI offers "Open session" instead of answer buttons.
|
||||
*/
|
||||
answerInTerminal?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalResolvedInfo {
|
||||
@@ -108,8 +118,26 @@ interface NotePromptArgs {
|
||||
* opens ALREADY acknowledged: see `notePrompt()`.
|
||||
*/
|
||||
watching?: string | null;
|
||||
/**
|
||||
* Alert-only item: answer in the terminal, not via Approve/Deny. See
|
||||
* `ApprovalItem.answerInTerminal`.
|
||||
*/
|
||||
answerInTerminal?: boolean;
|
||||
/**
|
||||
* An idle prompt that should open ALREADY acknowledged for a reason other than
|
||||
* `watching`, e.g. `cache keep-alive` when the turn that just ended was Codeman's own
|
||||
* prompt-cache nudge. Same consequence as `watching`: the item exists, only its alert is spent.
|
||||
*/
|
||||
preAcknowledged?: string;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
/**
|
||||
* A frame the caller already read, kept as the card's context when there is no
|
||||
* `capture`. Never parsed for options and never re-read: an item raised from the
|
||||
* screen (`web/screen-blocked.ts`) belongs to the session that keeps reading it, and
|
||||
* the option parser and staleness sweep here are shaped for Claude's dialogs.
|
||||
*/
|
||||
context?: string;
|
||||
}
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
@@ -261,11 +289,13 @@ export class ApprovalInbox {
|
||||
toolSummary: args.toolSummary,
|
||||
message: args.message,
|
||||
cwd: args.cwd,
|
||||
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
|
||||
};
|
||||
if (args.kind === 'idle' && args.watching) {
|
||||
if (args.kind === 'idle' && (args.preAcknowledged || args.watching)) {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
item.acknowledgedReason = `watching ${args.watching}`;
|
||||
item.acknowledgedReason = args.preAcknowledged ?? `watching ${args.watching}`;
|
||||
}
|
||||
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
|
||||
this.applyCapture(item, args.capture);
|
||||
this.items.set(args.sessionId, item);
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
|
||||
+16
-5
@@ -12,8 +12,10 @@
|
||||
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
|
||||
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
|
||||
* around it;
|
||||
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
|
||||
* folder listed both as a local case and as a linked one would make deleting it remove files;
|
||||
* - it must not be a cases directory or a direct child of one: those are what `GET /api/cases` lists
|
||||
* as local cases, so a case there is a plain Create New, and the same folder listed both as a local
|
||||
* case and as a linked one would make deleting it remove files. A folder further down is not
|
||||
* listed and is allowed;
|
||||
* - its parent must already exist (one folder is created, never a whole chain), and the folder
|
||||
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
|
||||
* job, and silently scaffolding into someone's project is the one thing this must never do);
|
||||
@@ -58,6 +60,8 @@ export interface NewCasePathContext {
|
||||
* listed as a local case, so it must not be registered as a linked one too.
|
||||
*/
|
||||
casesDirs?: readonly string[];
|
||||
/** What to do instead when the path is the cases folder or directly in it. Defaults to Create New's wording. */
|
||||
casesDirAdvice?: string;
|
||||
}
|
||||
|
||||
export type NewCasePathResult =
|
||||
@@ -98,9 +102,16 @@ export function blockedReason(
|
||||
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
|
||||
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
|
||||
}
|
||||
// Only the cases folder itself and its DIRECT children are listed as local cases (GET /api/cases reads
|
||||
// that one level), so only those would be registered twice. A deeper folder is not listed and is a
|
||||
// perfectly good linked case: with CODEMAN_CASES_PATH pointing at a broad root such as /mnt/user/Scripts,
|
||||
// refusing everything below it blocked most of the tree.
|
||||
for (const dir of ctx.casesDirs ?? []) {
|
||||
if (isWithin(absPath, dir)) {
|
||||
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
|
||||
if (absPath === dir) {
|
||||
return `That is the cases folder itself (${dir}); ${ctx.casesDirAdvice ?? 'create a case inside it with plain Create New (no custom folder)'}`;
|
||||
}
|
||||
if (dirname(absPath) === dir) {
|
||||
return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; ${ctx.casesDirAdvice ?? 'create it there with plain Create New (no custom folder)'}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -123,7 +134,7 @@ async function resolvedPolicy(ctx: NewCasePathContext): Promise<[NewCasePathCont
|
||||
Promise.all((ctx.casesDirs ?? []).map(realpathOr)),
|
||||
Promise.all(BLOCKED_SYSTEM_ROOTS.map(realpathOr)),
|
||||
]);
|
||||
return [{ home, dataDir, casesDirs }, systemRoots];
|
||||
return [{ home, dataDir, casesDirs, casesDirAdvice: ctx.casesDirAdvice }, systemRoots];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* @fileoverview Reads a claude session's edit history on request: the new bytes of its
|
||||
* transcripts (main + subagents) go into its `EditHistoryStore`, and the Bash image
|
||||
* candidates that came with them are confirmed against the files on disk.
|
||||
*
|
||||
* WHEN. Only when the panel asks (`GET …/edits?sync=1` on open and while it follows the
|
||||
* session, `POST …/edits/older`): no timer, no hook, no background read for sessions nobody
|
||||
* looks at. One sync per session runs at a time; requests during a run are folded into one
|
||||
* more forward read and/or one more older read afterwards.
|
||||
*
|
||||
* HOW MUCH. A forward read shares one budget over all files, main first. A file whose chunk
|
||||
* the shared budget cut short is not applied (the store would take a chunk with no line end
|
||||
* for a line longer than the budget) but read in the next pass; up to
|
||||
* {@link MAX_FORWARD_PASSES} passes per sync, the rest with the next one.
|
||||
*
|
||||
* NEW CONVERSATION. When the transcript path names another conversation (`/clear`, resume),
|
||||
* the previous transcript (same directory) is first read to its end, so edits it got after
|
||||
* the last read are not lost with its cursors; then the new one is read from its end.
|
||||
* "Load older" reaches back within the current conversation only.
|
||||
*
|
||||
* MEMORY. At most {@link MAX_LOADED_STORES} stores stay loaded; past that the least recently
|
||||
* used one without a run is written (if it changed) and dropped, and loaded again on demand.
|
||||
*
|
||||
* GONE. `remove()` (the session's record is deleted) marks the session gone, deletes the file
|
||||
* and then drops the session's slot: a run in flight stops after its current read and its
|
||||
* store no longer writes; a request during the removal reads nothing (`unavailable`, reason
|
||||
* `removed`). A session that comes back with the same id (reboot restore) starts afresh.
|
||||
* `sweep()` deletes, at startup, the stored history of sessions that no longer exist. A
|
||||
* subagent transcript that vanished loses its cursor (`dropSource`), not its entries.
|
||||
*
|
||||
* OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
|
||||
* with older lines per click; with more sources they take turns.
|
||||
*
|
||||
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
|
||||
* `unsupported` and are never read.
|
||||
*
|
||||
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
|
||||
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
|
||||
*
|
||||
* Tests: `test/edit-history-sync.test.ts`.
|
||||
*
|
||||
* @module web/edit-history-sync
|
||||
*/
|
||||
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { SessionRemote } from '../types/session.js';
|
||||
import type { EditSourceReadOptions, EditSourceRequest, EditSources, PathProbe } from '../edit-history-local-source.js';
|
||||
import type { ImageCandidate } from '../edit-history-parse.js';
|
||||
import { EditHistoryStore, READ_BUDGET_BYTES, type ConfirmedImage } from '../edit-history-store.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
|
||||
/** Slack around a Bash call's window for the image mtime check. */
|
||||
export const IMAGE_MTIME_SLACK_MS = 2000;
|
||||
/** Forward passes per sync when files grew by more than one budget. */
|
||||
export const MAX_FORWARD_PASSES = 3;
|
||||
/** Sources read back per "Load older" click, a full budget each. */
|
||||
export const MAX_OLDER_SOURCES = 3;
|
||||
/** Stores kept in memory; the least recently used one past this is dropped. */
|
||||
export const MAX_LOADED_STORES = 3;
|
||||
/** `reason` when the session has no transcript yet (no prompt yet, or not found). */
|
||||
export const NO_TRANSCRIPT = 'no-transcript';
|
||||
/** `reason` once the session's history was deleted. */
|
||||
export const REMOVED = 'removed';
|
||||
|
||||
export type EditHistoryState = 'ok' | 'unavailable' | 'unsupported';
|
||||
|
||||
export interface EditHistoryStatus {
|
||||
state: EditHistoryState;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The slice of a session this needs. */
|
||||
export interface EditHistorySession {
|
||||
id: string;
|
||||
mode: string;
|
||||
remote: SessionRemote | undefined;
|
||||
docker?: unknown;
|
||||
claudeSessionId: string | null;
|
||||
/** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
|
||||
claudeConfigDir?: string;
|
||||
}
|
||||
|
||||
export interface EditHistorySyncDeps {
|
||||
/** The session's main transcript path, or null when it has none yet. */
|
||||
transcriptPath(session: EditHistorySession): Promise<string | null>;
|
||||
readSources(
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number,
|
||||
opts?: EditSourceReadOptions
|
||||
): Promise<EditSources | null>;
|
||||
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
|
||||
loadStore(sessionId: string): Promise<EditHistoryStore>;
|
||||
/** Delete a session's stored history without loading it. */
|
||||
deleteStore(sessionId: string): Promise<void>;
|
||||
/** Delete the stored history of every session `keep` rejects; returns how many. */
|
||||
sweepStores(keep: (sessionId: string) => boolean): Promise<number>;
|
||||
budget?: number;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
older?: boolean;
|
||||
}
|
||||
|
||||
/** Why a session has no edit history, or undefined when it can have one. */
|
||||
export function unsupportedReason(session: EditHistorySession): string | undefined {
|
||||
if (session.remote) return 'remote';
|
||||
if (session.docker) return 'docker';
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One turn of the event loop, between synchronous parses of up to one budget each. */
|
||||
function breathe(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bash candidates whose probed file is a regular file with an mtime inside the call's window
|
||||
* (files claude sent to the user skip the window: they exist and were shown on purpose).
|
||||
* A candidate whose file version is already recorded is skipped: `known(path)` is the newest
|
||||
* entry time for that path, and an entry at or after `mtime − slack` saw this version: the
|
||||
* `ls *.png` right after the render, or a Write followed by a listing. Candidates are taken
|
||||
* in call order so a batch dedupes itself the same way.
|
||||
*/
|
||||
export function confirmImages(
|
||||
candidates: readonly ImageCandidate[],
|
||||
probe: (path: string) => PathProbe | null | undefined,
|
||||
known: (path: string) => number | undefined = () => undefined
|
||||
): ConfirmedImage[] {
|
||||
const out: ConfirmedImage[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
for (const c of [...candidates].sort((a, b) => a.end - b.end)) {
|
||||
for (const path of c.paths) {
|
||||
const p = probe(path);
|
||||
if (!p || p.kind !== 'file' || !p.mtimeMs) continue;
|
||||
const inWindow = p.mtimeMs >= c.start - IMAGE_MTIME_SLACK_MS && p.mtimeMs <= c.end + IMAGE_MTIME_SLACK_MS;
|
||||
if (!c.sent && !inWindow) continue;
|
||||
const entryPath = path.startsWith('~/') ? p.realPath : path;
|
||||
const last = Math.max(seen.get(entryPath) ?? -Infinity, known(entryPath) ?? -Infinity);
|
||||
if (last >= p.mtimeMs - IMAGE_MTIME_SLACK_MS) continue;
|
||||
seen.set(entryPath, c.end);
|
||||
const img: ConfirmedImage = {
|
||||
toolUseId: c.toolUseId,
|
||||
path: entryPath,
|
||||
ts: c.end,
|
||||
tool: c.sent ? 'sent' : 'bash',
|
||||
};
|
||||
if (c.agentId) img.agentId = c.agentId;
|
||||
out.push(img);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SyncWant {
|
||||
forward: boolean;
|
||||
older: boolean;
|
||||
}
|
||||
|
||||
interface SessionSlot {
|
||||
store?: Promise<EditHistoryStore>;
|
||||
status: EditHistoryStatus;
|
||||
running?: Promise<void>;
|
||||
again?: SyncWant;
|
||||
/** The history was deleted: nothing reads for this session any more. */
|
||||
gone?: boolean;
|
||||
/** The last source the previous "Load older" read; the next one starts after it. */
|
||||
olderAfter?: string;
|
||||
}
|
||||
|
||||
export class EditHistorySync {
|
||||
/** By session, least recently used first. */
|
||||
private readonly slots = new Map<string, SessionSlot>();
|
||||
/** Writes of dropped stores; loading that session again waits for its write. */
|
||||
private readonly unloading = new Map<string, Promise<void>>();
|
||||
private readonly budget: number;
|
||||
|
||||
constructor(private readonly deps: EditHistorySyncDeps) {
|
||||
this.budget = deps.budget ?? READ_BUDGET_BYTES;
|
||||
}
|
||||
|
||||
/** The session's store, loaded from disk on first use (one load for concurrent callers). */
|
||||
getStore(sessionId: string): Promise<EditHistoryStore> {
|
||||
return this.storeOf(sessionId, this.slot(sessionId));
|
||||
}
|
||||
|
||||
getStatus(sessionId: string): EditHistoryStatus {
|
||||
return { ...this.slot(sessionId).status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read what is new (or, with `older`, the next older block). Resolves when this
|
||||
* request's run is done; never rejects. A call during a run queues one more run.
|
||||
*/
|
||||
async sync(session: EditHistorySession, opts: SyncOptions = {}): Promise<EditHistoryStatus> {
|
||||
const why = unsupportedReason(session);
|
||||
const slot = this.slot(session.id);
|
||||
if (slot.gone) return { ...slot.status };
|
||||
if (why) {
|
||||
slot.status = { state: 'unsupported', reason: why };
|
||||
return { ...slot.status };
|
||||
}
|
||||
const want: SyncWant = { forward: !opts.older, older: !!opts.older };
|
||||
if (slot.running) {
|
||||
slot.again = {
|
||||
forward: (slot.again?.forward ?? false) || want.forward,
|
||||
older: (slot.again?.older ?? false) || want.older,
|
||||
};
|
||||
await slot.running;
|
||||
// The queued run starts when the current one ends; wait for it too.
|
||||
if (slot.running) await slot.running;
|
||||
return { ...slot.status };
|
||||
}
|
||||
slot.running = this.runLoop(session, slot, want);
|
||||
try {
|
||||
await slot.running;
|
||||
} finally {
|
||||
slot.running = undefined;
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return { ...slot.status };
|
||||
}
|
||||
|
||||
/** Write every loaded store's pending changes (server shutdown). */
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
...[...this.slots.values()].map(async (slot) => (await slot.store?.catch(() => undefined))?.settle()),
|
||||
...this.unloading.values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session's stored history (its record is gone). While it runs the session is
|
||||
* marked, so a run in flight or a request meanwhile cannot load and write a new store for
|
||||
* it; then its slot goes, and a session that comes back with this id starts afresh.
|
||||
*/
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
const slot = this.slot(sessionId);
|
||||
slot.gone = true;
|
||||
slot.again = undefined;
|
||||
slot.status = { state: 'unavailable', reason: REMOVED };
|
||||
try {
|
||||
const store = await slot.store?.catch(() => undefined);
|
||||
if (store) {
|
||||
await store.remove();
|
||||
} else {
|
||||
await this.unloading.get(sessionId);
|
||||
await this.deps.deleteStore(sessionId);
|
||||
}
|
||||
await slot.running;
|
||||
} finally {
|
||||
if (this.slots.get(sessionId) === slot) this.slots.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored history of sessions that no longer exist (startup: left behind by a
|
||||
* crash during deletion, or by an older build). A session in use here is kept.
|
||||
*/
|
||||
sweep(exists: (sessionId: string) => boolean): Promise<number> {
|
||||
return this.deps.sweepStores((id) => this.slots.has(id) || exists(id));
|
||||
}
|
||||
|
||||
/** The session's slot, now the most recently used. */
|
||||
private slot(sessionId: string): SessionSlot {
|
||||
const slot = this.slots.get(sessionId) ?? { status: { state: 'ok' } };
|
||||
this.slots.delete(sessionId);
|
||||
this.slots.set(sessionId, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private storeOf(sessionId: string, slot: SessionSlot): Promise<EditHistoryStore> {
|
||||
if (!slot.store) {
|
||||
const loading = (async () => {
|
||||
await this.unloading.get(sessionId);
|
||||
return this.deps.loadStore(sessionId);
|
||||
})();
|
||||
slot.store = loading;
|
||||
loading.catch(() => {
|
||||
if (slot.store === loading) slot.store = undefined;
|
||||
});
|
||||
this.unloadIdle(slot);
|
||||
}
|
||||
return slot.store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Past {@link MAX_LOADED_STORES}, drop the least recently used stores no run uses: each is
|
||||
* written if it changed, and a later request loads it again (after that write).
|
||||
*/
|
||||
private unloadIdle(keep?: SessionSlot): void {
|
||||
let loaded = 0;
|
||||
for (const slot of this.slots.values()) if (slot.store) loaded++;
|
||||
for (const [sessionId, slot] of this.slots) {
|
||||
if (loaded <= MAX_LOADED_STORES) break;
|
||||
if (!slot.store || slot.running || slot.gone || slot === keep) continue;
|
||||
this.slots.delete(sessionId);
|
||||
loaded--;
|
||||
const writing: Promise<void> = slot.store
|
||||
.then((store) => store.settle())
|
||||
.catch((err) => {
|
||||
console.warn(`[EditHistory] Saving the history of ${sessionId} failed:`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.unloading.get(sessionId) === writing) this.unloading.delete(sessionId);
|
||||
});
|
||||
this.unloading.set(sessionId, writing);
|
||||
}
|
||||
}
|
||||
|
||||
private async runLoop(session: EditHistorySession, slot: SessionSlot, want: SyncWant): Promise<void> {
|
||||
let next: SyncWant | undefined = want;
|
||||
while (next && !slot.gone) {
|
||||
if (next.forward) await this.runOnce(session, slot, false);
|
||||
if (next.older && !slot.gone) await this.runOnce(session, slot, true);
|
||||
next = slot.again;
|
||||
slot.again = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async runOnce(session: EditHistorySession, slot: SessionSlot, older: boolean): Promise<void> {
|
||||
let status: EditHistoryStatus;
|
||||
try {
|
||||
// This slot's store: a run still in flight after `remove()` cannot reach a later one.
|
||||
const store = await this.storeOf(session.id, slot);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session);
|
||||
let found: boolean;
|
||||
if (!path) {
|
||||
found = false;
|
||||
} else if (older) {
|
||||
found = await this.readOlder(path, store, slot);
|
||||
} else {
|
||||
found = true;
|
||||
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
|
||||
const r = await this.readForward(path, store);
|
||||
found = r.found;
|
||||
if (!r.more) break;
|
||||
}
|
||||
}
|
||||
status = found ? { state: 'ok' } : { state: 'unavailable', reason: NO_TRANSCRIPT };
|
||||
} catch (err) {
|
||||
status = { state: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!slot.gone) slot.status = status;
|
||||
}
|
||||
|
||||
private forwardRequests(store: EditHistoryStore): EditSourceRequest[] {
|
||||
const out: EditSourceRequest[] = [];
|
||||
for (const relPath of store.knownSources()) {
|
||||
const c = store.getCursor(relPath);
|
||||
if (c) out.push({ relPath, offset: c.offset });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One forward read of all sources. `more` = some file has unread bytes after it, or was cut
|
||||
* short by the shared budget and left for the next pass. A transcript other than the stored
|
||||
* one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
|
||||
*/
|
||||
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
|
||||
const own = this.storedTranscript(path, store);
|
||||
if (own !== path && store.knownSources().length > 0) {
|
||||
const previous = await this.readFrom(own, store, this.forwardRequests(store));
|
||||
if (previous.more) return { found: true, more: true };
|
||||
}
|
||||
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path of the conversation the store holds cursors for: `path` itself, or the previous
|
||||
* conversation's transcript next to it (`/clear` and resume keep the project directory).
|
||||
*/
|
||||
private storedTranscript(path: string, store: EditHistoryStore): string {
|
||||
const known = store.getTranscriptId();
|
||||
return known === null || basename(path) === `${known}.jsonl` ? path : join(dirname(path), `${known}.jsonl`);
|
||||
}
|
||||
|
||||
private async readFrom(
|
||||
path: string,
|
||||
store: EditHistoryStore,
|
||||
requests: EditSourceRequest[]
|
||||
): Promise<{ found: boolean; more: boolean }> {
|
||||
const res = await this.deps.readSources(path, requests, this.budget);
|
||||
if (!res) return { found: false, more: false };
|
||||
this.dropMissing(store, requests, res);
|
||||
for (const [agentId, text] of Object.entries(res.metas)) {
|
||||
if (!store.hasAgentMeta(agentId)) store.setAgentMeta(agentId, text);
|
||||
}
|
||||
const requested = new Set(requests.map((r) => r.relPath));
|
||||
let more = false;
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
if (store.isRemoved()) break;
|
||||
// A requested file reads on from its cursor, a new one its tail.
|
||||
const available = requested.has(f.relPath) ? f.size - f.startOffset : f.size;
|
||||
if (f.chunk.length < Math.min(this.budget, available)) {
|
||||
more = true;
|
||||
continue;
|
||||
}
|
||||
parsed = true;
|
||||
const r = store.apply({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
if (f.startOffset + f.chunk.length < f.size) more = true;
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return { found: true, more };
|
||||
}
|
||||
|
||||
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
|
||||
if (!store.backfillTruncated) return true;
|
||||
// Older lines belong to the stored conversation, even before a forward read moved on.
|
||||
path = this.storedTranscript(path, store);
|
||||
const sources = store.sourcesWithOlder().sort();
|
||||
if (sources.length === 0) return true;
|
||||
// Take turns: start after the last source the previous click read.
|
||||
const after = slot.olderAfter;
|
||||
const first =
|
||||
after === undefined
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
sources.findIndex((s) => s > after)
|
||||
);
|
||||
const picked = [...sources.slice(first), ...sources.slice(0, first)].slice(0, MAX_OLDER_SOURCES);
|
||||
const requests: EditSourceRequest[] = [];
|
||||
for (const relPath of picked) {
|
||||
const range = store.olderRange(relPath, this.budget);
|
||||
if (range) requests.push({ relPath, offset: range.offset, len: range.len });
|
||||
}
|
||||
if (requests.length === 0) return true;
|
||||
slot.olderAfter = picked[picked.length - 1];
|
||||
const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
|
||||
if (!res) return false;
|
||||
if (res.transcriptId !== store.getTranscriptId()) return true;
|
||||
this.dropMissing(store, requests, res);
|
||||
const wanted = new Set(requests.map((r) => r.relPath));
|
||||
const candidates: ImageCandidate[] = [];
|
||||
let parsed = false;
|
||||
for (const f of res.files) {
|
||||
if (!wanted.has(f.relPath)) continue;
|
||||
// Each file is up to one budget (8 MiB) of synchronous parsing: yield between them.
|
||||
if (parsed) await breathe();
|
||||
parsed = true;
|
||||
if (slot.gone) return true;
|
||||
const r = store.applyOlder({
|
||||
transcriptId: res.transcriptId,
|
||||
source: f.relPath,
|
||||
startOffset: f.startOffset,
|
||||
chunk: f.chunk,
|
||||
size: f.size,
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Requested files the reader no longer lists are gone: their cursors go too. */
|
||||
private dropMissing(store: EditHistoryStore, requests: readonly EditSourceRequest[], res: EditSources): void {
|
||||
const listed = new Set(res.files.map((f) => f.relPath));
|
||||
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
|
||||
}
|
||||
|
||||
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
|
||||
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
|
||||
// A removed store keeps nothing: do not probe agent-printed paths for it.
|
||||
if (paths.length === 0 || store.isRemoved()) return;
|
||||
let probes: Array<PathProbe | null>;
|
||||
try {
|
||||
probes = await this.deps.probe(paths);
|
||||
} catch {
|
||||
// The edits of this read are in; only its images are lost. The read itself succeeded.
|
||||
return;
|
||||
}
|
||||
const byPath = new Map(paths.map((p, i) => [p, probes[i]]));
|
||||
const newest = new Map<string, number>();
|
||||
for (const e of store.list()) newest.set(e.path, Math.max(newest.get(e.path) ?? -Infinity, e.ts));
|
||||
store.addImageEntries(
|
||||
confirmImages(
|
||||
candidates,
|
||||
(p) => byPath.get(p),
|
||||
(p) => newest.get(p)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+204
-50
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
MAX_AUTH_SESSIONS,
|
||||
AUTH_FAILURE_MAX,
|
||||
AUTH_IP_FAILURE_MAX,
|
||||
AUTH_FAILURE_WINDOW_MS,
|
||||
} from '../../config/auth-config.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import {
|
||||
capabilityFromProxyPath,
|
||||
capabilityFromReferer,
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
lostWebviewFramePage,
|
||||
LOST_FRAME_PAGE_CSP,
|
||||
} from '../webview-proxy.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser, type UserRecord } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
// ownership helpers default to a synthetic admin (see route-helpers).
|
||||
@@ -52,6 +54,8 @@ interface AuthState {
|
||||
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-username Basic-auth failure bucket (multi-user only). */
|
||||
userFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-(username, client IP) Basic-auth failure bucket (multi-user only). */
|
||||
userIpFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/** Rate-limit response for a client that exceeded the failure cap. */
|
||||
@@ -75,6 +79,22 @@ function parseBasicAuth(header?: string): { username: string; password: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key of the multi-user in-flight verify map: a SHA-256 digest of (client IP,
|
||||
* normalized username, password), so the plaintext password is never held as a key.
|
||||
* The JSON array keeps the three parts unambiguous for any strings (a NUL-joined
|
||||
* string is not, since a username may carry a NUL). Never logged or persisted, and
|
||||
* gone from the map once its verify settles.
|
||||
*/
|
||||
function verifyFlightKey(clientIp: string, normUser: string, password: string): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([clientIp, normUser, password]))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/** What one Basic verify decided, shared by the request that ran it and every joiner. */
|
||||
type BasicVerifyOutcome = { user: UserRecord } | { user: null; limited: boolean };
|
||||
|
||||
/**
|
||||
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
|
||||
* single-user and multi-user auth hooks so the security-critical logic has ONE
|
||||
@@ -183,6 +203,20 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request is a GET for a rendered HTML file (`/html-view/<cap>/...`) with
|
||||
* a live capability. Same reasoning as the web-tab proxy: the page is sandboxed into
|
||||
* an opaque origin, so its asset requests carry no session cookie. Read-only by
|
||||
* construction (safe methods only), and the Host allowlist still applies. OPTIONS
|
||||
* is the page's CORS preflight, which registerSecurityHeaders answers itself and
|
||||
* never hands to a route.
|
||||
*/
|
||||
function hasValidHtmlViewCapability(req: FastifyRequest): boolean {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') return false;
|
||||
const cap = capabilityFromHtmlViewPath((req.url ?? '').split('?')[0]);
|
||||
return !!cap && htmlViewCapabilities.resolve(cap) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A web-tab frame that navigated itself off the proxy prefix (see
|
||||
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
|
||||
@@ -288,6 +322,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
qrAuthFailures: null,
|
||||
hookSecretFailures: null,
|
||||
userFailures: null,
|
||||
userIpFailures: null,
|
||||
};
|
||||
|
||||
// Always declare req.authUser so downstream reads are safe (single-user leaves it
|
||||
@@ -335,13 +370,33 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
const hookSecretFailures = state.hookSecretFailures;
|
||||
|
||||
if (multiUser) {
|
||||
// Per-username failure bucket: a botnet can't brute-force one account across
|
||||
// many IPs, and one user behind a NAT can't lock out everyone else.
|
||||
// Per-username failure bucket: failed guesses against one account answer 429
|
||||
// from any IP once it fills, and one user behind a NAT can't lock out everyone
|
||||
// else. Checked after the verify, so it never denies a correct password.
|
||||
state.userFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
|
||||
// Per-(username, IP) failure bucket: checked BEFORE the verify, so it is what
|
||||
// caps verified guesses against one account from one source. Keyed on the pair,
|
||||
// so other IPs can't lock the account out and two users behind one shared IP
|
||||
// don't lock each other out. Same decay as the other buckets: it holds one key
|
||||
// per (IP, username) pair whose verify failed or is still in flight, swept by
|
||||
// the same TTL, so like them it grows only with scrypt verifies.
|
||||
state.userIpFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(
|
||||
app,
|
||||
https,
|
||||
authSessions,
|
||||
authFailures,
|
||||
hookSecretFailures,
|
||||
state.userFailures,
|
||||
state.userIpFailures,
|
||||
basePath
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -368,6 +423,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// Rendered HTML file, likewise authenticated by its capability.
|
||||
if (hasValidHtmlViewCapability(req)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||
if (serveLostWebviewFrame(req, reply)) return;
|
||||
|
||||
@@ -445,8 +505,9 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
|
||||
/**
|
||||
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
|
||||
* `username:password` against the user store, mints an identity-carrying cookie,
|
||||
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
|
||||
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
|
||||
* decorates `req.authUser`, enforces the per-IP, per-(username, IP) and per-username
|
||||
* rate limits, and the `mustChangePassword` lockbox. Identical logins in flight at once
|
||||
* share one verify. The single-user hook above is left untouched.
|
||||
*/
|
||||
function registerMultiUserAuthHook(
|
||||
app: FastifyInstance,
|
||||
@@ -455,6 +516,7 @@ function registerMultiUserAuthHook(
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>,
|
||||
userIpFailures: StaleExpirationMap<string, number>,
|
||||
basePath = ''
|
||||
): void {
|
||||
const setSessionCookie = (reply: FastifyReply, token: string) =>
|
||||
@@ -495,6 +557,75 @@ function registerMultiUserAuthHook(
|
||||
return false;
|
||||
};
|
||||
|
||||
// Basic verifies in flight, by verifyFlightKey(). A request whose identical
|
||||
// credentials from the same IP are already being verified awaits that verify
|
||||
// instead of starting its own. Each entry is removed when its verify settles, so the
|
||||
// map never holds more than the distinct verifies in flight.
|
||||
const verifiesInFlight = new Map<string, Promise<BasicVerifyOutcome>>();
|
||||
|
||||
// The verify plus its once-only side effects, run by the first request for a key.
|
||||
// A success clears the three buckets and records the login; a failure is counted
|
||||
// here once (the per-IP and pair buckets already hold it: the caller reserved them).
|
||||
const verifyOnce = async (
|
||||
username: string,
|
||||
password: string,
|
||||
normUser: string,
|
||||
clientIp: string,
|
||||
pairKey: string
|
||||
): Promise<BasicVerifyOutcome> => {
|
||||
const result = await verifyPassword(username, password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
if (rehash) void setPassword(user.username, password).catch(() => {});
|
||||
void touchLastLogin(user.username).catch(() => {});
|
||||
authFailures.delete(clientIp);
|
||||
userFailures.delete(normUser);
|
||||
userIpFailures.delete(pairKey);
|
||||
return { user };
|
||||
}
|
||||
// Failed guess: only the per-username bucket counts it here. Once that bucket
|
||||
// reaches the cap, further FAILED attempts get 429 from any IP, but this path is
|
||||
// only reached on a wrong password, so it can never deny a correct one. Its count
|
||||
// is re-read after the await, so concurrent failures against one account each add one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
return { user: null, limited: uFail >= AUTH_FAILURE_MAX };
|
||||
};
|
||||
|
||||
// Answer one request from a shared outcome. Every successful request, joiners
|
||||
// included, gets its own cookie session under the usual capacity rules.
|
||||
const sendBasicOutcome = (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
outcome: BasicVerifyOutcome,
|
||||
normUser: string
|
||||
): void => {
|
||||
if (outcome.user) {
|
||||
const { user } = outcome;
|
||||
const token = randomBytes(32).toString('hex');
|
||||
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
|
||||
authSessions.set(token, {
|
||||
ip: req.ip,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
});
|
||||
req.authUser = { username: user.username, role: user.role };
|
||||
setSessionCookie(reply, token);
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
if (outcome.limited) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
};
|
||||
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass' || bypass === 'rejected') return;
|
||||
@@ -507,6 +638,8 @@ function registerMultiUserAuthHook(
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req, basePath)) return;
|
||||
// Rendered HTML file, likewise authenticated by its capability.
|
||||
if (hasValidHtmlViewCapability(req)) return;
|
||||
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
|
||||
if (serveLostWebviewFrame(req, reply)) return;
|
||||
|
||||
@@ -547,58 +680,69 @@ function registerMultiUserAuthHook(
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Basic Auth against the user store (scrypt verify).
|
||||
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
|
||||
// per-username bucket here; see below).
|
||||
// 2. Basic Auth against the user store (scrypt verify). Three failure buckets:
|
||||
// - per IP (AUTH_IP_FAILURE_MAX), checked first: bounds scrypt CPU cost from one
|
||||
// source. It refuses even a correct password, so it is wider than
|
||||
// AUTH_FAILURE_MAX: behind a shared egress IP (NAT, a tunnel) a narrow cap
|
||||
// would let a few wrong guesses lock everyone out.
|
||||
// - per (username, IP) (AUTH_FAILURE_MAX), checked BEFORE the verify: caps
|
||||
// verified guesses against one account from one source. Only that IP is
|
||||
// refused for that account; the same account from another IP still logs in.
|
||||
// - per username (AUTH_FAILURE_MAX), checked AFTER the verify; see below.
|
||||
// Neither pre-verify refusal is counted, since nothing was verified (and counting
|
||||
// a locked pair's retries would fill the shared per-IP bucket for everyone).
|
||||
// A request that passes both gates RESERVES its slot in both buckets before the
|
||||
// scrypt await, as if it had already failed. Counting only after the verify let a
|
||||
// concurrent burst pass the gates on the same stale read: 29 wrong guesses plus the
|
||||
// right password, all in flight at once from one IP, logged in. A success deletes
|
||||
// the reservation with the rest of the buckets; a failure is not counted again.
|
||||
// A request whose IDENTICAL credentials from the same IP are already being verified
|
||||
// joins that verify instead: it skips both gates, takes no slot, runs no scrypt and
|
||||
// gets the same outcome, since it adds no guess and no CPU. Reserving for each of
|
||||
// them refused a correct password with the slots that password itself had filled,
|
||||
// which is every reload after a restart: cookie sessions live in memory, so the
|
||||
// browser replays its cached Basic header on many parallel requests at once. A
|
||||
// request with no verify in flight for its credentials is gated as normal.
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
const normUser = creds ? creds.username.trim().toLowerCase() : '';
|
||||
const flightKey = creds ? verifyFlightKey(clientIp, normUser, creds.password) : '';
|
||||
const shared = creds ? verifiesInFlight.get(flightKey) : undefined;
|
||||
if (shared) {
|
||||
sendBasicOutcome(req, reply, await shared, normUser);
|
||||
return;
|
||||
}
|
||||
const ipFail = authFailures.get(clientIp) ?? 0;
|
||||
if (ipFail >= AUTH_FAILURE_MAX) {
|
||||
if (ipFail >= AUTH_IP_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
if (creds) {
|
||||
const normUser = creds.username.trim().toLowerCase();
|
||||
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
|
||||
// per-username bucket before verifying let throwaway IPs lock out a known account
|
||||
// (incl. admin) even with the correct password. A correct password must always
|
||||
// win and self-heal both buckets, regardless of the username-failure count.
|
||||
const result = await verifyPassword(creds.username, creds.password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
|
||||
void touchLastLogin(user.username).catch(() => {});
|
||||
authFailures.delete(clientIp);
|
||||
userFailures.delete(normUser);
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
|
||||
authSessions.set(token, {
|
||||
ip: clientIp,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
});
|
||||
req.authUser = { username: user.username, role: user.role };
|
||||
setSessionCookie(reply, token);
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
// IPs never contain a newline, so this key is unambiguous for any username.
|
||||
const pairKey = `${clientIp}\n${normUser}`;
|
||||
const pairFail = userIpFailures.get(pairKey) ?? 0;
|
||||
if (pairFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userIpFailures, pairKey);
|
||||
return;
|
||||
}
|
||||
// Failed guess: count it against BOTH buckets. Once the per-username bucket
|
||||
// reaches the cap, further FAILED attempts get 429 (throttles distributed
|
||||
// brute-force), but this path is only reached on a wrong password, so it can
|
||||
// never deny a correct one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
// Reserve both slots and publish this verify for joiners, from the values the
|
||||
// gates just read. Correct only while nothing between those reads and these
|
||||
// writes awaits: keep it that way.
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
if (uFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
userIpFailures.set(pairKey, pairFail + 1);
|
||||
// Security: the per-username bucket is consulted only AFTER the verify, and only
|
||||
// FAILED attempts are throttled by it. Consulting it before verifying let
|
||||
// throwaway IPs lock out a known account (incl. admin) even with the correct
|
||||
// password. A correct password that passes the two gates above always wins and
|
||||
// self-heals the per-IP, per-username and this pair's buckets.
|
||||
const verify = verifyOnce(creds.username, creds.password, normUser, clientIp, pairKey);
|
||||
verifiesInFlight.set(flightKey, verify);
|
||||
let outcome: BasicVerifyOutcome;
|
||||
try {
|
||||
outcome = await verify;
|
||||
} finally {
|
||||
verifiesInFlight.delete(flightKey);
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
sendBasicOutcome(req, reply, outcome, normUser);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -711,6 +855,16 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
|
||||
// A rendered HTML page (`/html-view/<cap>/...`) is opaque-origin for the same
|
||||
// reason, so a fetch() of its own `data.json` with a non-safelisted header
|
||||
// preflights with `Origin: null`. Answer it here, read-only: the route serves
|
||||
// GET and HEAD only, and no credentials are involved (`*` is valid for both).
|
||||
if (hasValidHtmlViewCapability(req)) {
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Access-Control-Allow-Methods', 'GET, HEAD');
|
||||
reply.header('Access-Control-Allow-Headers', '*');
|
||||
reply.header('Access-Control-Max-Age', '600');
|
||||
}
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
@@ -7,15 +7,28 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when unauthenticated network exposure is acceptable: either a password is
|
||||
* set (auth active) or the operator explicitly acknowledged it. Used by the
|
||||
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
|
||||
* True when an unauthenticated non-loopback BIND is acceptable: either a password is
|
||||
* set (auth active) or the operator explicitly acknowledged it. Quiets the startup
|
||||
* warning only; it does not cover the Cloudflare tunnel (#706), which has its own
|
||||
* acknowledgement below.
|
||||
*/
|
||||
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
|
||||
if (process.env.CODEMAN_PASSWORD) return true;
|
||||
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the operator opted in to an unauthenticated public Cloudflare tunnel:
|
||||
* `--allow-unauthenticated-tunnel` (passed in as `allowFlag`) or
|
||||
* CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL. Used by the tunnel-enable guard (COD-55) in
|
||||
* place of a login. Kept apart from the network acknowledgement on purpose (#706):
|
||||
* install.sh writes that one for every passwordless LAN install, and it must never
|
||||
* also publish the machine to the internet. install.sh never writes this one.
|
||||
*/
|
||||
export function isUnauthenticatedTunnelAcknowledged(allowFlag = false): boolean {
|
||||
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL);
|
||||
}
|
||||
|
||||
export function isLoopbackBindHost(host: string): boolean {
|
||||
const normalized = host
|
||||
.trim()
|
||||
|
||||
@@ -38,6 +38,12 @@ export const UPLOADS_DIR = '.codeman-uploads';
|
||||
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];
|
||||
/**
|
||||
* Where files a person sent from Discord land (discord-bridge.ts). Named here with the others so the
|
||||
* image watcher can skip it without importing the bridge. Not an upload dir: neither the sweep nor a
|
||||
* session delete touches it.
|
||||
*/
|
||||
export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
||||
|
||||
/**
|
||||
* Every directory a session's uploads sit in, current first. Both consumers
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||
*
|
||||
* The Claude status-telemetry route and host Codex poll merge their latest
|
||||
* The Claude status-telemetry route and the host Codex and Copilot polls merge their latest
|
||||
* values here. The SSE init snapshot (`getLightState`) replays the combined
|
||||
* value so the header "Plan Usage Limits" chip shows immediately on a fresh
|
||||
* page load / SSE reconnect — before either source emits another sample, and
|
||||
@@ -15,8 +15,14 @@
|
||||
let latest: Record<string, unknown> | null = null;
|
||||
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
|
||||
// The Claude status route replaces the Claude part; the host-polled providers ride along.
|
||||
const codex = latest?.codex;
|
||||
latest = { ...value, ...(codex !== undefined ? { codex } : {}) };
|
||||
const copilot = latest?.copilot;
|
||||
latest = {
|
||||
...value,
|
||||
...(codex !== undefined ? { codex } : {}),
|
||||
...(copilot !== undefined ? { copilot } : {}),
|
||||
};
|
||||
return latest;
|
||||
}
|
||||
|
||||
@@ -25,6 +31,11 @@ export function setLatestCodexPlanUsage(value: object | null): Record<string, un
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function setLatestCopilotPlanUsage(value: object | null): Record<string, unknown> {
|
||||
latest = { ...(latest ?? {}), copilot: value };
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function getLatestPlanUsage(): Record<string, unknown> | null {
|
||||
return latest;
|
||||
}
|
||||
@@ -15,3 +15,4 @@ export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
export type { CronPort } from './cron-port.js';
|
||||
export type { TabLayoutPort } from './tab-layout-port.js';
|
||||
export type { KeepWarmPort } from './keep-warm-port.js';
|
||||
@@ -35,6 +35,8 @@ export interface InfraPort {
|
||||
readonly scheduledRuns: Map<string, ScheduledRun>;
|
||||
readonly teamWatcher: TeamWatcher;
|
||||
readonly tunnelManager: TunnelManager;
|
||||
/** `--allow-unauthenticated-tunnel` was passed to `codeman web` (the env var is read per request, #706). */
|
||||
readonly allowUnauthenticatedTunnel: boolean;
|
||||
readonly pushStore: PushSubscriptionStore;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @fileoverview Port for the prompt-cache keep-warm scheduler (`prompt-cache-keepwarm.ts`).
|
||||
* Routes only ever trigger a re-evaluation; the rulebook and the timers stay in the service.
|
||||
*/
|
||||
|
||||
import type { KeepWarmScheduler } from '../../prompt-cache-keepwarm.js';
|
||||
|
||||
export interface KeepWarmPort {
|
||||
readonly keepWarm: KeepWarmScheduler;
|
||||
}
|
||||
@@ -5,11 +5,30 @@
|
||||
|
||||
import type { Session } from '../../session.js';
|
||||
import type { SessionState } from '../../types.js';
|
||||
import type { TabLayout } from '../../tab-layout.js';
|
||||
import type { SessionPlacementHint } from '../../tab-layout-service.js';
|
||||
|
||||
export interface SessionPort {
|
||||
readonly sessions: ReadonlyMap<string, Session>;
|
||||
addSession(session: Session): Promise<void>;
|
||||
/**
|
||||
* The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the
|
||||
* session routes register, so other server code reads the same per-CLI transcript the viewer does.
|
||||
*/
|
||||
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
|
||||
/**
|
||||
* Register a created session with the owner's tab layout. `placement.tabGroupId`
|
||||
* puts it at the end of that group in the same layout write (ignored when the
|
||||
* group is not in the owner's layout); without one (and without a parent that
|
||||
* places it), `placement.caseName`'s default session group does the same.
|
||||
* Resolves to the committed layout.
|
||||
*/
|
||||
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
|
||||
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
|
||||
/**
|
||||
* Delete a session's stored edit history once its record is gone. `cleanupSession` does it
|
||||
* itself; this is for removing a record without a live session (DELETE route).
|
||||
*/
|
||||
forgetEditHistory(sessionId: string): void;
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
persistSessionStateNow(session: Session): void;
|
||||
|
||||
+1225
-182
File diff suppressed because it is too large.
Load diff
@@ -261,6 +261,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
|
||||
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
|
||||
`</div>`;
|
||||
} else if (item.answerInTerminal) {
|
||||
// Supervised-hook CLIs (DeepSeek Harness): keystrokes are unverifiable, so
|
||||
// Approve/Deny would hit the answer route and fail. Offer only Open session.
|
||||
actions = `<span class="approval-terminal-hint" data-i18n-skip>Answer in the terminal</span>`;
|
||||
} else if (item.options && item.options.length) {
|
||||
actions = item.options
|
||||
.map(
|
||||
|
||||
+1298
-9
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* @fileoverview Edit history: a header button (next to Attachments) and the floating panel it opens
|
||||
* right below, listing every file edit the ACTIVE session's agent (and its subagents) made, newest
|
||||
* first.
|
||||
*
|
||||
* OPTIONAL and per-device: `showEditHistory` (App Settings → Header & Panels → Header), default
|
||||
* OFF. While it is off the button never shows and nothing is requested. The panel DRIVES the
|
||||
* server's reading, which never runs in the background: it reads
|
||||
* `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
|
||||
* only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
|
||||
* tool that can change files (the existing transcript watcher's event) and on its `session:idle`
|
||||
* (the end of a turn). No poll, and nothing at all while the panel is closed.
|
||||
*
|
||||
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
|
||||
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
|
||||
* - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
|
||||
* on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
|
||||
* routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the
|
||||
* workspace registers it on click with `notify: false` (`_registerExternalPreview`, which
|
||||
* enforces the attachment guard and its extension allowlist). A path from the transcript is
|
||||
* never served by a route of its own.
|
||||
* - `backfillTruncated` offers "Load older" (`POST /api/sessions/:id/edits/older`).
|
||||
*
|
||||
* Paths, agent descriptions and diffs are untrusted text: they are only ever written with
|
||||
* `textContent`, never `innerHTML`.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
|
||||
* @dependency api-client.js (this._api, this._apiJson; resolved at call time)
|
||||
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath)
|
||||
* @dependency i18n.js (window.codemanT)
|
||||
* @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** SSE bursts (one event per tool call) collapse into one read. */
|
||||
const EDIT_HISTORY_SSE_DEBOUNCE_MS = 600;
|
||||
/** Tools whose end can mean changed files (Agent/Task: a subagent's edits; SendUserFile: an image). */
|
||||
const EDIT_HISTORY_FOLLOW_TOOLS = new Set([
|
||||
'Edit',
|
||||
'Write',
|
||||
'MultiEdit',
|
||||
'NotebookEdit',
|
||||
'Bash',
|
||||
'Agent',
|
||||
'Task',
|
||||
'SendUserFile',
|
||||
]);
|
||||
/** Per-device view toggles (a viewer convenience, so plain localStorage). */
|
||||
const EDIT_HISTORY_GROUP_KEY = 'codeman-edit-history-group';
|
||||
const EDIT_HISTORY_MAIN_ONLY_KEY = 'codeman-edit-history-main-only';
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-device setting, default OFF. */
|
||||
isEditHistoryEnabled() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
return (settings.showEditHistory ?? defaults.showEditHistory ?? false) === true;
|
||||
},
|
||||
|
||||
/** Translate an English source string (i18n.js), with `{var}` interpolation. */
|
||||
_eht(source, vars) {
|
||||
if (typeof window.codemanT === 'function') return window.codemanT(source, vars);
|
||||
return vars ? source.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : source;
|
||||
},
|
||||
|
||||
/** A row badge ("new"/"edit") through its scoped i18n key; English shows the bare word. */
|
||||
_ehBadge(word) {
|
||||
const key = `Edit history badge: ${word}`;
|
||||
const out = this._eht(key);
|
||||
return out === key ? word : out;
|
||||
},
|
||||
|
||||
/** Called from applyHeaderVisibilitySettings() (boot and every settings save). */
|
||||
applyEditHistoryVisibility() {
|
||||
const on = this.isEditHistoryEnabled();
|
||||
// Marker class, not [hidden]: the base rule is display:inline-flex !important (src/web/CLAUDE.md).
|
||||
this.$('editHistoryBtn')?.classList.toggle('btn-edit-history--hidden', !on);
|
||||
if (!on) {
|
||||
this.closeEditHistoryPanel();
|
||||
this._editHistory = null;
|
||||
this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1; // an in-flight read must not repaint
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Per-device view toggles ─────────────────────────────────────────────
|
||||
|
||||
_editHistoryPref(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
_setEditHistoryPref(key, on) {
|
||||
try {
|
||||
localStorage.setItem(key, on ? '1' : '0');
|
||||
} catch {
|
||||
/* private window / blocked storage: the toggle still works for this page */
|
||||
}
|
||||
},
|
||||
|
||||
isEditHistoryGrouped() {
|
||||
return this._editHistoryGroupOn ?? (this._editHistoryGroupOn = this._editHistoryPref(EDIT_HISTORY_GROUP_KEY));
|
||||
},
|
||||
|
||||
isEditHistoryMainOnly() {
|
||||
return this._editHistoryMainOnly ?? (this._editHistoryMainOnly = this._editHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY));
|
||||
},
|
||||
|
||||
setEditHistoryGrouped(on) {
|
||||
this._editHistoryGroupOn = !!on;
|
||||
this._setEditHistoryPref(EDIT_HISTORY_GROUP_KEY, !!on);
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
setEditHistoryMainOnly(on) {
|
||||
this._editHistoryMainOnly = !!on;
|
||||
this._setEditHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY, !!on);
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The data for the session on screen, or null. */
|
||||
_currentEditHistory() {
|
||||
const h = this._editHistory;
|
||||
return h && h.sessionId === this.activeSessionId ? h : null;
|
||||
},
|
||||
|
||||
_editHistoryBase(sid) {
|
||||
return `/api/sessions/${encodeURIComponent(sid)}/edits`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the active session's history and repaint. `older` asks the server for entries before the
|
||||
* backfill window (POST), otherwise the normal read. Answers for another session are dropped.
|
||||
*/
|
||||
async refreshEditHistory({ older = false } = {}) {
|
||||
if (!this.isEditHistoryEnabled()) return;
|
||||
const sid = this.activeSessionId || null;
|
||||
if (!sid) {
|
||||
this._editHistory = null;
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const epoch = (this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1);
|
||||
const prev = this._currentEditHistory();
|
||||
if (!prev) {
|
||||
// Another session's list must not stay on screen while this one loads.
|
||||
this._editHistory = { sessionId: sid, state: 'loading', data: null, diffs: new Map(), open: new Set() };
|
||||
this._renderEditHistoryPanel();
|
||||
} else if (older) {
|
||||
prev.loadingOlder = true;
|
||||
this._renderEditHistoryPanel();
|
||||
}
|
||||
const base = this._editHistoryBase(sid);
|
||||
const data = older
|
||||
? await this._apiJson(`${base}/older`, { method: 'POST', body: {} })
|
||||
: await this._apiJson(`${base}?sync=1`);
|
||||
if (epoch !== this._editHistoryEpoch || sid !== this.activeSessionId || !this.isEditHistoryEnabled()) return;
|
||||
const cur = this._currentEditHistory() || {
|
||||
sessionId: sid,
|
||||
diffs: new Map(),
|
||||
open: new Set(),
|
||||
};
|
||||
cur.loadingOlder = false;
|
||||
if (data && typeof data === 'object') {
|
||||
cur.state = 'ok';
|
||||
cur.data = data;
|
||||
} else if (!cur.data) {
|
||||
cur.state = 'error';
|
||||
}
|
||||
this._editHistory = cur;
|
||||
this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
loadOlderEditHistory() {
|
||||
return this.refreshEditHistory({ older: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE `transcript:tool_end` ({ sessionId, toolName }), and the end of a turn through
|
||||
* `onEditHistorySessionIdle`: while the panel is open on that session, re-read after a tool that
|
||||
* can change files, and when the turn ends (a parallel tool call reports only one tool end; a
|
||||
* background subagent writes after the tool end that started it). Closed: nothing.
|
||||
*/
|
||||
_onEditHistoryTranscriptEvent(data) {
|
||||
if (!data || data.sessionId !== this.activeSessionId || !this._isEditHistoryPanelOpen()) return;
|
||||
if (data.toolName !== undefined && !EDIT_HISTORY_FOLLOW_TOOLS.has(data.toolName)) return;
|
||||
if (!this.isEditHistoryEnabled()) return;
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = setTimeout(() => {
|
||||
this._editHistoryDebounce = null;
|
||||
if (this._isEditHistoryPanelOpen()) this.refreshEditHistory();
|
||||
}, EDIT_HISTORY_SSE_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by app.js's `session:idle` handler ({ id }): the end of a turn. Claude's interactive
|
||||
* transcript has no `result` line, so the transcript watcher's `transcript:complete` never fires
|
||||
* for these sessions.
|
||||
*/
|
||||
onEditHistorySessionIdle(sessionId) {
|
||||
this._onEditHistoryTranscriptEvent({ sessionId });
|
||||
},
|
||||
|
||||
/** Called by selectSession(): the open panel follows the active session. */
|
||||
onEditHistoryActiveSession(sessionId) {
|
||||
if (!this.isEditHistoryEnabled() || !this._isEditHistoryPanelOpen()) return;
|
||||
if (this._editHistory?.sessionId === sessionId) return;
|
||||
this.refreshEditHistory();
|
||||
},
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
_isEditHistoryPanelOpen() {
|
||||
return !!this.$('editHistoryPanel')?.classList.contains('visible');
|
||||
},
|
||||
|
||||
toggleEditHistoryPanel() {
|
||||
if (this._isEditHistoryPanelOpen()) {
|
||||
this.closeEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const panel = this.$('editHistoryPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.add('visible');
|
||||
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'true');
|
||||
const group = this.$('editHistoryGroupToggle');
|
||||
if (group) group.checked = this.isEditHistoryGrouped();
|
||||
const main = this.$('editHistoryMainOnlyToggle');
|
||||
if (main) main.checked = this.isEditHistoryMainOnly();
|
||||
this._ensureEditHistoryPanelDrag();
|
||||
this._renderEditHistoryPanel();
|
||||
this.refreshEditHistory();
|
||||
},
|
||||
|
||||
closeEditHistoryPanel() {
|
||||
const panel = this.$('editHistoryPanel');
|
||||
if (panel) {
|
||||
panel.classList.remove('visible');
|
||||
// Reset a dragged position so it reopens at the default spot (a resized size is kept).
|
||||
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
|
||||
}
|
||||
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'false');
|
||||
clearTimeout(this._editHistoryDebounce);
|
||||
this._editHistoryDebounce = null;
|
||||
},
|
||||
|
||||
/** Drag by the header (pointer events: mouse, pen and touch). One set of listeners per page. */
|
||||
_ensureEditHistoryPanelDrag() {
|
||||
const panel = this.$('editHistoryPanel');
|
||||
const handle = panel?.querySelector('.edit-history-header');
|
||||
if (!panel || !handle || handle._dragReady) return;
|
||||
handle._dragReady = true;
|
||||
let drag = null;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
||||
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
|
||||
panel.style.left = `${rect.left}px`;
|
||||
panel.style.top = `${rect.top}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
// Pin the size too: the phone layout stretches between left and right, which a drag gives up.
|
||||
panel.style.width = `${rect.width}px`;
|
||||
panel.style.height = `${rect.height}px`;
|
||||
handle.setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const maxX = window.innerWidth - panel.offsetWidth - 4;
|
||||
const maxY = window.innerHeight - panel.offsetHeight - 4;
|
||||
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
|
||||
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
|
||||
});
|
||||
const end = (e) => {
|
||||
drag = null;
|
||||
handle.releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
handle.addEventListener('pointerup', end);
|
||||
handle.addEventListener('pointercancel', end);
|
||||
},
|
||||
|
||||
_ehEl(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
},
|
||||
|
||||
/** "14:03:22", with the date in front when it is not today. */
|
||||
_editHistoryTime(ts) {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString()} ${time}`;
|
||||
},
|
||||
|
||||
/** Open `path` in the shared file preview overlay (panels-ui.js). */
|
||||
_openEditHistoryPreview(path) {
|
||||
const sid = this.activeSessionId;
|
||||
if (sid && path) this.openFilePreview?.(path, sid);
|
||||
},
|
||||
|
||||
_editHistoryUrl(url) {
|
||||
return typeof CodemanBase !== 'undefined' && CodemanBase.url ? CodemanBase.url(url) : url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Thumbnail URL for an image entry inside the workspace: the confined `file-raw` route the file
|
||||
* preview uses. An image outside the workspace gets none: showing it needs an attachment id
|
||||
* (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the
|
||||
* session's real attachments out of its capped registry. Clicking it opens the file preview,
|
||||
* which registers it like any other external file. SVGs get none either (`file-raw` serves
|
||||
* them as a download).
|
||||
*/
|
||||
_editHistoryThumbSrc(path, sid) {
|
||||
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
|
||||
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
|
||||
_renderEditHistoryPanel() {
|
||||
const body = this.$('editHistoryBody');
|
||||
const count = this.$('editHistoryCount');
|
||||
if (!body || !this._isEditHistoryPanelOpen()) return;
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const focusKey = body.contains(document.activeElement)
|
||||
? document.activeElement.closest?.('[data-edit-key]')?.dataset.editKey
|
||||
: null;
|
||||
const scroll = body.scrollTop;
|
||||
body.replaceChildren();
|
||||
if (count) count.textContent = '';
|
||||
|
||||
if (!this.activeSessionId) {
|
||||
body.append(el('div', 'edit-history-empty', t('Open a session to see its edits.')));
|
||||
return;
|
||||
}
|
||||
const h = this._currentEditHistory();
|
||||
if (!h || h.state === 'loading') {
|
||||
body.append(el('div', 'edit-history-empty', t('Reading the edit history…')));
|
||||
return;
|
||||
}
|
||||
if (h.state === 'error' || !h.data) {
|
||||
body.append(el('div', 'edit-history-empty', t('Could not read the edit history.')));
|
||||
return;
|
||||
}
|
||||
const data = h.data;
|
||||
if (data.state === 'unsupported') {
|
||||
body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
|
||||
return;
|
||||
}
|
||||
if (data.state === 'unavailable') {
|
||||
body.append(
|
||||
el(
|
||||
'div',
|
||||
'edit-history-note edit-history-note--warn',
|
||||
data.reason === 'no-transcript'
|
||||
? t(
|
||||
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
|
||||
)
|
||||
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const all = Array.isArray(data.entries) ? data.entries : [];
|
||||
const entries = this.isEditHistoryMainOnly() ? all.filter((e) => !e.agentId) : all;
|
||||
if (count) count.textContent = all.length ? String(entries.length) : '';
|
||||
if (!all.length) {
|
||||
body.append(el('div', 'edit-history-empty', t('No edits yet.')));
|
||||
} else if (!entries.length) {
|
||||
body.append(el('div', 'edit-history-empty', t('No edits by the main agent.')));
|
||||
} else if (this.isEditHistoryGrouped()) {
|
||||
const groups = new Map();
|
||||
for (const e of entries) {
|
||||
const key = e.path || e.relPath;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
}
|
||||
for (const items of groups.values()) body.append(this._editHistoryGroup(items, data, h));
|
||||
} else {
|
||||
const list = el('div', 'edit-history-list');
|
||||
for (const e of entries) list.append(...this._editHistoryEntry(e, data, h, true));
|
||||
body.append(list);
|
||||
}
|
||||
|
||||
if (data.backfillTruncated) {
|
||||
const more = el(
|
||||
'button',
|
||||
'btn-toolbar btn-sm edit-history-older',
|
||||
h.loadingOlder ? t('Loading older edits…') : t('Load older')
|
||||
);
|
||||
more.type = 'button';
|
||||
more.disabled = !!h.loadingOlder;
|
||||
more.addEventListener('click', () => this.loadOlderEditHistory());
|
||||
body.append(more);
|
||||
}
|
||||
|
||||
body.scrollTop = scroll;
|
||||
if (focusKey) {
|
||||
const again = [...body.querySelectorAll('[data-edit-key]')].find((n) => n.dataset.editKey === focusKey);
|
||||
again?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
/** One file with all its edits (newest first), for the "group by file" view. */
|
||||
_editHistoryGroup(items, data, h) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const first = items[0];
|
||||
const group = el('section', 'edit-history-group');
|
||||
const head = el('div', 'edit-history-group-head');
|
||||
head.append(this._editHistoryPathButton(first));
|
||||
const code = items.filter((e) => e.kind !== 'image');
|
||||
if (code.length) {
|
||||
const added = code.reduce((s, e) => s + (Number(e.added) || 0), 0);
|
||||
const removed = code.reduce((s, e) => s + (Number(e.removed) || 0), 0);
|
||||
head.append(this._editHistoryStats(added, removed));
|
||||
}
|
||||
head.append(el('span', 'edit-history-group-count', String(items.length)));
|
||||
group.append(head);
|
||||
for (const e of items) group.append(...this._editHistoryEntry(e, data, h, false));
|
||||
return group;
|
||||
},
|
||||
|
||||
/**
|
||||
* The path as folder + file name: the folder is cut from its start when the row is narrow, so a
|
||||
* long absolute path (an image in claude's scratchpad) still shows which file it is.
|
||||
*/
|
||||
_editHistoryPathButton(e) {
|
||||
const shown = e.relPath || e.path || '';
|
||||
const btn = this._ehEl('button', 'edit-history-path');
|
||||
const cut = shown.lastIndexOf('/') + 1;
|
||||
if (cut > 0) {
|
||||
const dir = this._ehEl('span', 'edit-history-path-dir');
|
||||
const bdi = this._ehEl('bdi', null, shown.slice(0, cut));
|
||||
bdi.dir = 'ltr';
|
||||
dir.append(bdi);
|
||||
btn.append(dir);
|
||||
}
|
||||
btn.append(this._ehEl('span', 'edit-history-path-name', shown.slice(cut)));
|
||||
btn.type = 'button';
|
||||
btn.title = e.path || e.relPath || '';
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this._openEditHistoryPreview(e.path);
|
||||
});
|
||||
return btn;
|
||||
},
|
||||
|
||||
_editHistoryStats(added, removed) {
|
||||
const stats = this._ehEl('span', 'edit-history-stats');
|
||||
stats.append(this._ehEl('span', 'edit-history-add', `+${added}`));
|
||||
stats.append(this._ehEl('span', 'edit-history-del', `−${removed}`));
|
||||
return stats;
|
||||
},
|
||||
|
||||
/** A row, followed by its diff when the row is open. `withPath` false inside a file group. */
|
||||
_editHistoryEntry(e, data, h, withPath) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const isImage = e.kind === 'image';
|
||||
const row = el('div', `edit-history-entry edit-history-entry--${isImage ? 'image' : 'code'}`);
|
||||
row.dataset.editKey = e.id;
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'button');
|
||||
row.append(el('span', 'edit-history-time', this._editHistoryTime(e.ts)));
|
||||
|
||||
let badge;
|
||||
if (isImage) {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--image');
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('width', '12');
|
||||
svg.setAttribute('height', '12');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
for (const [tag, attrs] of [
|
||||
['rect', { x: '3', y: '4', width: '18', height: '16', rx: '2' }],
|
||||
['circle', { cx: '9', cy: '10', r: '2' }],
|
||||
['path', { d: 'M21 17l-5-5-9 8' }],
|
||||
]) {
|
||||
const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
||||
svg.append(n);
|
||||
}
|
||||
badge.append(svg);
|
||||
badge.title = t('Image');
|
||||
} else if (e.created) {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--new', this._ehBadge('new'));
|
||||
badge.title = t('File created');
|
||||
} else {
|
||||
badge = el('span', 'edit-history-badge edit-history-badge--edit', this._ehBadge('edit'));
|
||||
badge.title = t('File edited ({tool})', { tool: e.tool || '' });
|
||||
}
|
||||
row.append(badge);
|
||||
|
||||
if (withPath) row.append(this._editHistoryPathButton(e));
|
||||
row.append(el('span', 'edit-history-spacer'));
|
||||
if (!isImage) row.append(this._editHistoryStats(Number(e.added) || 0, Number(e.removed) || 0));
|
||||
|
||||
if (e.agentId) {
|
||||
const agent = data.agents?.[e.agentId];
|
||||
const chip = el('span', 'edit-history-agent', agent?.description || e.agentId);
|
||||
const bits = [agent?.agentType, agent?.worktreeBranch].filter(Boolean);
|
||||
chip.title = bits.join(' · ') || t('Subagent');
|
||||
row.append(chip);
|
||||
}
|
||||
|
||||
const out = [row];
|
||||
if (isImage) {
|
||||
row.title = t('Open in the file preview');
|
||||
const open = () => this._openEditHistoryPreview(e.path);
|
||||
row.addEventListener('click', open);
|
||||
row.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
const src = this._editHistoryThumbSrc(e.path, h.sessionId);
|
||||
if (src) {
|
||||
const img = el('img', 'edit-history-thumb');
|
||||
img.loading = 'lazy';
|
||||
img.alt = e.relPath || '';
|
||||
img.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
open();
|
||||
});
|
||||
img.addEventListener('error', () => img.remove());
|
||||
img.src = src;
|
||||
out.push(img);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const isOpen = h.open.has(e.id);
|
||||
row.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
row.title = isOpen ? t('Hide the diff') : t('Show what changed');
|
||||
const toggle = () => this.toggleEditHistoryDiff(e.id);
|
||||
row.addEventListener('click', toggle);
|
||||
row.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
if (isOpen) {
|
||||
row.classList.add('edit-history-entry--open');
|
||||
out.push(this._editHistoryDiffView(h.diffs.get(e.id)));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Open or close one entry's diff; the first open loads it, later ones reuse it. */
|
||||
async toggleEditHistoryDiff(entryId) {
|
||||
const h = this._currentEditHistory();
|
||||
if (!h) return;
|
||||
if (h.open.has(entryId)) {
|
||||
h.open.delete(entryId);
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
h.open.add(entryId);
|
||||
const cached = h.diffs.get(entryId);
|
||||
if (cached && cached.state !== 'error') {
|
||||
this._renderEditHistoryPanel();
|
||||
return;
|
||||
}
|
||||
const view = { state: 'loading' };
|
||||
h.diffs.set(entryId, view);
|
||||
this._renderEditHistoryPanel();
|
||||
const res = await this._api(`${this._editHistoryBase(h.sessionId)}/${encodeURIComponent(entryId)}`);
|
||||
let body = null;
|
||||
try {
|
||||
body = res ? await res.json() : null;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (h.diffs.get(entryId) !== view) return;
|
||||
if (res && res.ok && body?.success && typeof body.data?.diff === 'string') {
|
||||
view.state = 'ok';
|
||||
view.diff = body.data.diff;
|
||||
view.truncated = !!body.data.truncated;
|
||||
} else {
|
||||
view.state = 'error';
|
||||
view.error = body?.error || this._eht('Could not read the diff.');
|
||||
}
|
||||
if (this._editHistory === h) this._renderEditHistoryPanel();
|
||||
},
|
||||
|
||||
/** The diff block under an open row, drawn with the Git panel's classes. */
|
||||
_editHistoryDiffView(view) {
|
||||
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
|
||||
const t = (s, v) => this._eht(s, v);
|
||||
const wrap = el('div', 'edit-history-diff');
|
||||
if (!view || view.state === 'loading') {
|
||||
wrap.append(el('div', 'edit-history-empty', t('Reading the diff…')));
|
||||
return wrap;
|
||||
}
|
||||
if (view.state === 'error') {
|
||||
wrap.append(el('div', 'edit-history-empty', view.error));
|
||||
return wrap;
|
||||
}
|
||||
if (!view.diff.trim()) {
|
||||
wrap.append(el('div', 'edit-history-empty', t('No textual changes.')));
|
||||
} else {
|
||||
const pre = el('pre', 'git-diff');
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const line of view.diff.replace(/\n$/, '').split('\n')) {
|
||||
let cls = 'git-diff-line';
|
||||
// The API sends hunks only (no `---`/`+++` headers), so a `---` line is a removed `--` line.
|
||||
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
|
||||
else if (line.startsWith('+')) cls += ' git-diff-line--add';
|
||||
else if (line.startsWith('-')) cls += ' git-diff-line--del';
|
||||
frag.append(el('span', cls, line + '\n'));
|
||||
}
|
||||
pre.append(frag);
|
||||
wrap.append(pre);
|
||||
}
|
||||
if (view.truncated)
|
||||
wrap.append(el('div', 'edit-history-more', t('Diff cut short: it is larger than the viewer shows.')));
|
||||
return wrap;
|
||||
},
|
||||
});
|
||||
@@ -4448,6 +4448,7 @@ var GestureController = class {
|
||||
|
||||
// packages/gesture-control/src/codeman/entry.ts
|
||||
var TAB_SELECTOR = ".session-tab";
|
||||
var TILE_SELECTOR = "#tileGrid .tile";
|
||||
var PANEL_SELECTOR = ".cg-float";
|
||||
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
|
||||
var DOCK_SELECTOR = ".session-tabs";
|
||||
@@ -4457,6 +4458,8 @@ var FLOAT_W = 640;
|
||||
var FLOAT_H = 420;
|
||||
var DETACH_PULL_PX = 70;
|
||||
var TAP_CANCEL_PX = 45;
|
||||
var TILE_MOVE_PX = 40;
|
||||
var TILE_GHOST_W = 280;
|
||||
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
|
||||
var GestureBridge = class {
|
||||
constructor() {
|
||||
@@ -4533,7 +4536,7 @@ var GestureBridge = class {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add("on");
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
this.status.textContent = "on: pinch a tab, tile, window, or button";
|
||||
} catch (err) {
|
||||
const msg = describeError(err);
|
||||
this.status.textContent = `failed: ${msg}`;
|
||||
@@ -4588,6 +4591,30 @@ var GestureBridge = class {
|
||||
this.status.textContent = "moving window";
|
||||
return;
|
||||
}
|
||||
const tileId = window.app?.tileAtPoint?.(x2, y2) ?? null;
|
||||
const tileEl = tileId ? this.hitClosest(x2, y2, TILE_SELECTOR) : null;
|
||||
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
|
||||
const rect = tileEl.getBoundingClientRect();
|
||||
const ghost = this.tileGhost(tileEl, rect);
|
||||
document.body.append(ghost);
|
||||
tileEl.classList.add("tile--dragging");
|
||||
this.grabs.set(hand, {
|
||||
kind: "tile",
|
||||
id: tileId,
|
||||
el: tileEl,
|
||||
ghost,
|
||||
offsetX: Math.max(0, Math.min(x2 - rect.left, rect.width)),
|
||||
offsetY: Math.max(0, Math.min(y2 - rect.top, rect.height)),
|
||||
ox: x2,
|
||||
oy: y2,
|
||||
armed: false,
|
||||
target: null,
|
||||
out: false
|
||||
});
|
||||
this.positionGhost(ghost, x2, y2);
|
||||
this.status.textContent = "moving tile";
|
||||
return;
|
||||
}
|
||||
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
if (tab && id) {
|
||||
@@ -4599,7 +4626,7 @@ var GestureBridge = class {
|
||||
ghost.style.height = `${rect.height}px`;
|
||||
document.body.append(ghost);
|
||||
tab.classList.add("cg-grabbed");
|
||||
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
|
||||
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false, target: null });
|
||||
this.positionGhost(ghost, x2, y2);
|
||||
return;
|
||||
}
|
||||
@@ -4613,13 +4640,30 @@ var GestureBridge = class {
|
||||
}
|
||||
onDrag(hand, x2, y2) {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === "tile") {
|
||||
this.positionGhost(grab.ghost, x2, y2);
|
||||
if (!grab.armed && Math.hypot(x2 - grab.ox, y2 - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
|
||||
if (!grab.armed) return;
|
||||
const app = window.app;
|
||||
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
|
||||
this.setTileTarget(grab, target);
|
||||
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x2, y2);
|
||||
grab.out = out;
|
||||
grab.ghost.classList.toggle("cg-armed", !!target);
|
||||
grab.ghost.classList.toggle("cg-out", out);
|
||||
this.status.textContent = target ? target.kind === "tile" ? "release to swap tiles" : "release to move the tile here" : out ? "release to open in a new window" : "moving tile";
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "tab") {
|
||||
this.positionGhost(grab.ghost, x2, y2);
|
||||
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
|
||||
if (pulled !== grab.armed) {
|
||||
const target = pulled ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
|
||||
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
|
||||
this.setTileTarget(grab, target);
|
||||
if (pulled !== grab.armed || targetChanged) {
|
||||
grab.armed = pulled;
|
||||
grab.ghost.classList.toggle("cg-armed", pulled);
|
||||
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
|
||||
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4641,16 +4685,40 @@ var GestureBridge = class {
|
||||
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
this.status.textContent = "on: pinch a tab, tile, window, or button";
|
||||
}
|
||||
}
|
||||
onDrop(hand, x2, y2) {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === "tile") {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove("tile--dragging");
|
||||
this.setTileTarget(grab, null);
|
||||
const app = window.app;
|
||||
if (!grab.armed) {
|
||||
this.flash("cancelled");
|
||||
return;
|
||||
}
|
||||
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
|
||||
if (target) {
|
||||
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? "moved tile" : "cancelled");
|
||||
} else if (grab.out && !app?.isOverTileGrid?.(x2, y2)) {
|
||||
const opened = app?.detachTileAtPoint?.(grab.id, x2, y2, { offsetX: grab.offsetX, offsetY: grab.offsetY });
|
||||
this.flash(opened ? "opened in a new window" : "pop-out blocked: allow popups for this site");
|
||||
} else {
|
||||
this.flash("cancelled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "tab") {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
if (grab.armed) this.floatPanel(grab.id, x2, y2);
|
||||
this.setTileTarget(grab, null);
|
||||
const target = grab.armed ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
|
||||
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? "tiled" : "cancelled");
|
||||
else if (grab.armed) this.floatPanel(grab.id, x2, y2);
|
||||
else this.flash("cancelled");
|
||||
return;
|
||||
}
|
||||
@@ -4775,11 +4843,41 @@ var GestureBridge = class {
|
||||
ghost.style.left = `${x2}px`;
|
||||
ghost.style.top = `${y2}px`;
|
||||
}
|
||||
/** A small copy of a tile to follow the hand: its header (a copy: no
|
||||
* listeners come along) over an empty body, in the tile's proportions. */
|
||||
tileGhost(tileEl, rect) {
|
||||
const ghost = el("div", "cg-ghost cg-tile-ghost");
|
||||
const width = Math.min(TILE_GHOST_W, rect.width);
|
||||
ghost.style.width = `${width}px`;
|
||||
ghost.style.height = `${Math.max(48, Math.round(width * rect.height / Math.max(1, rect.width)))}px`;
|
||||
const header = tileEl.querySelector(".tile-header");
|
||||
if (header) {
|
||||
const copy = header.cloneNode(true);
|
||||
copy.removeAttribute("draggable");
|
||||
ghost.append(copy);
|
||||
}
|
||||
ghost.append(el("div", "cg-tile-ghost-body"));
|
||||
return ghost;
|
||||
}
|
||||
/** Highlights the tile or empty cell a carried session would drop onto
|
||||
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
|
||||
setTileTarget(grab, target) {
|
||||
if (grab.target?.el !== target?.el) {
|
||||
grab.target?.el.classList.remove("tile--drop-target");
|
||||
target?.el.classList.add("tile--drop-target");
|
||||
}
|
||||
grab.target = target;
|
||||
}
|
||||
cancelAllGrabs() {
|
||||
for (const grab of this.grabs.values()) {
|
||||
if (grab.kind === "tab") {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === "tile") {
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove("tile--dragging");
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === "panel") {
|
||||
grab.panel.el.style.pointerEvents = "";
|
||||
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
||||
@@ -4791,6 +4889,7 @@ var GestureBridge = class {
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.clear();
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
|
||||
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t2) => t2.classList.remove("tile--dragging"));
|
||||
}
|
||||
onStatus(fps, hands) {
|
||||
const { width, height } = this.canvas;
|
||||
@@ -4863,6 +4962,13 @@ function injectStyles() {
|
||||
outline: 2px solid #38bdf8; outline-offset: -2px;
|
||||
}
|
||||
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
|
||||
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
|
||||
.cg-tile-ghost {
|
||||
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
|
||||
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
|
||||
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
|
||||
.cg-dock {
|
||||
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
|
||||
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
|
||||
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
|
||||
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
|
||||
* session changes or the window regains focus). The route is read-only and offline: it never fetches
|
||||
* or changes the repository, so the "behind" number reflects the last `git fetch`, which the panel
|
||||
* footer says. Remote (SSH) and Docker sessions answer `unsupported` and show no indicator.
|
||||
* session changes or the window regains focus). The status route is read-only: it never changes the
|
||||
* repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching
|
||||
* happens when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`,
|
||||
* src/git-fetch.ts), or on the interval when the session is on: this is the only git command the Git panel
|
||||
* runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too,
|
||||
* but only when the user asked for them at that moment). Remote (SSH)
|
||||
* and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched.
|
||||
*
|
||||
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
|
||||
* only ever written with `textContent`, never `innerHTML`.
|
||||
@@ -265,6 +269,78 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.refreshGitStatus({ fresh: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch, on this click only: one `git fetch --quiet` per repository the panel shows
|
||||
* (`POST /api/sessions/:id/git-fetch`). It is the only git command the Git panel runs that writes, and the
|
||||
* only one Codeman runs on a timer, and it updates remote-tracking refs only: nothing here commits or
|
||||
* touches the working tree, so an agent's own work is left alone, though a fetch does hold git's ref lock,
|
||||
* so an agent's `git pull` in the same repository can hit the same `cannot lock ref` while it runs. One
|
||||
* click means one fetch per repository: the button is busy and a second click is ignored while it runs, and
|
||||
* a click that joins a fetch already running reports that run, not itself. The panel then re-reads, so the
|
||||
* "behind" number reflects what this fetch brought.
|
||||
*/
|
||||
async fetchGitStatusNow() {
|
||||
const sid = this.activeSessionId || null;
|
||||
const btn = this.$('gitStatusFetchBtn');
|
||||
if (!sid || !btn || this._gitFetchInFlight) return;
|
||||
this._gitFetchInFlight = true;
|
||||
this._renderGitFetchButton();
|
||||
let report = null;
|
||||
try {
|
||||
const query = new URLSearchParams(this.gitStatusLimits());
|
||||
report = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-fetch?${query}`, { method: 'POST' });
|
||||
} catch {
|
||||
report = null;
|
||||
}
|
||||
// A fetch that lands after a session switch is not shown for the session on screen now; the refs it
|
||||
// moved are still on disk, so that session's own read reflects them.
|
||||
if (sid === this.activeSessionId) this._gitStatusFetch = report ? { sessionId: sid, data: report } : null;
|
||||
this._gitFetchInFlight = false;
|
||||
await this.refreshGitStatus({ fresh: true });
|
||||
},
|
||||
|
||||
/** The last Fetch this window ran for the session on screen, or null. */
|
||||
_currentGitFetch() {
|
||||
const f = this._gitStatusFetch;
|
||||
return f && f.sessionId === this.activeSessionId && f.data ? f.data : null;
|
||||
},
|
||||
|
||||
/** Busy while a fetch runs, and never enabled for a session there is nothing to fetch for. */
|
||||
_renderGitFetchButton() {
|
||||
const btn = this.$('gitStatusFetchBtn');
|
||||
if (!btn) return;
|
||||
const overview = this._currentGitStatus();
|
||||
const fetchable = !!this.activeSessionId && !!overview && overview.state === 'ok';
|
||||
btn.disabled = this._gitFetchInFlight || !fetchable;
|
||||
if (this._gitFetchInFlight) btn.setAttribute('aria-busy', 'true');
|
||||
else btn.removeAttribute('aria-busy');
|
||||
},
|
||||
|
||||
/** What the last Fetch brought: a summary line, then one row per repository it could not fetch. */
|
||||
_gitFetchNote() {
|
||||
const report = this._currentGitFetch();
|
||||
if (!report || report.state !== 'ok' || !report.repos?.length) return null;
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
const failed = report.repos.filter((r) => !r.ok);
|
||||
const count = report.repos.length;
|
||||
const note = el('div', 'git-status-fetch-note');
|
||||
note.append(
|
||||
el(
|
||||
'div',
|
||||
'git-status-fetch-summary',
|
||||
`Fetched ${new Date(report.fetchedAt).toLocaleTimeString()}: ${count - failed.length} of ${count} ${count === 1 ? 'repository' : 'repositories'}.`
|
||||
)
|
||||
);
|
||||
for (const r of failed) {
|
||||
const row = el('div', 'git-status-fetch-row');
|
||||
row.append(el('span', 'git-status-repo-name', r.name));
|
||||
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
|
||||
row.append(el('span', 'git-status-repo-unreadable', `⚠ ${r.failure?.message || 'git fetch failed'}`));
|
||||
note.append(row);
|
||||
}
|
||||
return note;
|
||||
},
|
||||
|
||||
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
|
||||
_ensureGitStatusPanelDrag() {
|
||||
const panel = this.$('gitStatusPanel');
|
||||
@@ -313,6 +389,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!body) return;
|
||||
const overview = this._currentGitStatus();
|
||||
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
|
||||
this._renderGitFetchButton();
|
||||
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
|
||||
const focusKey = body.contains(document.activeElement)
|
||||
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
|
||||
@@ -379,8 +456,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
const note = this._gitFetchNote();
|
||||
if (note) body.append(note);
|
||||
|
||||
if (foot) {
|
||||
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
|
||||
// Runtime text (the panel footer carries data-i18n-skip), so naming the interval here does not fight the
|
||||
// translator. It says what actually runs: this click, or the interval the user turned on.
|
||||
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only apart from Fetch: Codeman fetches on your click, or on the interval you turned on in Settings, and never commits or changes the repository, so “behind” is as of the last fetch.`;
|
||||
}
|
||||
if (focusKey) {
|
||||
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
|
||||
@@ -449,7 +531,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
|
||||
if (data.behind) {
|
||||
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
|
||||
behind.title = 'As of the last git fetch: Codeman never fetches.';
|
||||
behind.title =
|
||||
'As of the last git fetch. Click Fetch in this window, or turn on the fetch interval in Settings, to bring it current.';
|
||||
line.append(behind);
|
||||
}
|
||||
} else if (data.hasRemote) {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @fileoverview A draggable divider on the left edge of the header's right-hand toolbar.
|
||||
*
|
||||
* Dragging it left gives the toolbar (stats, plan usage, buttons) more room; dragging it right narrows the
|
||||
* toolbar, which then wraps onto a second line instead of squeezing the session tabs out. The width is a
|
||||
* device-local convenience kept in localStorage; double-click or Home resets it. The handle is absolutely
|
||||
* positioned inside #headerRight, so it takes no layout room and is simply hidden where the tabs are not in
|
||||
* the header (sidebar, vertical rail, phones).
|
||||
*
|
||||
* @loadorder after app.js; self-contained, no CodemanApp dependency.
|
||||
*/
|
||||
(function () {
|
||||
const STORAGE_KEY = 'codeman-header-right-width';
|
||||
const MIN_WIDTH = 160;
|
||||
const MIN_TABS_ROOM = 240;
|
||||
const KEY_STEP = 16;
|
||||
|
||||
/** Keep a requested toolbar width between the minimum and what leaves the tabs some room. */
|
||||
function clampWidth(width, headerWidth, brandWidth) {
|
||||
const max = Math.max(MIN_WIDTH, headerWidth - brandWidth - MIN_TABS_ROOM);
|
||||
return Math.round(Math.min(max, Math.max(MIN_WIDTH, width)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of
|
||||
* the labels go: window labels first, then provider names (shortened), then, in the Compact style, the
|
||||
* percentages (the rings stay, and the chip's tooltip keeps the numbers).
|
||||
*/
|
||||
function densityFor(width) {
|
||||
if (width >= 560) return '';
|
||||
if (width >= 400) return 'tight';
|
||||
if (width >= 280) return 'dense';
|
||||
return 'tiny';
|
||||
}
|
||||
|
||||
function readStored() {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(STORAGE_KEY));
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(width) {
|
||||
try {
|
||||
if (width === null) localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, String(width));
|
||||
} catch {
|
||||
/* storage blocked: the width lasts until the page reloads */
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const header = document.querySelector('.header');
|
||||
const right = document.getElementById('headerRight');
|
||||
if (!header || !right || right.querySelector('.header-divider')) return;
|
||||
const brand = header.querySelector('.header-brand');
|
||||
const root = document.documentElement;
|
||||
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'header-divider';
|
||||
handle.setAttribute('role', 'separator');
|
||||
handle.setAttribute('aria-orientation', 'vertical');
|
||||
handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)');
|
||||
handle.setAttribute('aria-valuemin', String(MIN_WIDTH));
|
||||
handle.tabIndex = 0;
|
||||
right.insertBefore(handle, right.firstChild);
|
||||
|
||||
// The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading
|
||||
// that needs more room), and the request is what comes back when the window grows again.
|
||||
let wanted = readStored();
|
||||
|
||||
const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 });
|
||||
const current = () => right.getBoundingClientRect().width;
|
||||
|
||||
/** The narrowest the toolbar can be without a reading poking out past its left edge (the divider). */
|
||||
function contentMinimum() {
|
||||
const style = getComputedStyle(right);
|
||||
const frame = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth);
|
||||
let widest = 0;
|
||||
for (const child of right.children) {
|
||||
if (child === handle || child.offsetParent === null) continue;
|
||||
widest = Math.max(widest, child.getBoundingClientRect().width, child.scrollWidth);
|
||||
}
|
||||
return Math.ceil(widest + frame);
|
||||
}
|
||||
|
||||
function setDensity(width) {
|
||||
const density = densityFor(width);
|
||||
if (density) root.setAttribute('data-header-density', density);
|
||||
else root.removeAttribute('data-header-density');
|
||||
}
|
||||
|
||||
/** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */
|
||||
function settle() {
|
||||
if (wanted === null) {
|
||||
root.style.removeProperty('--header-right-width');
|
||||
root.removeAttribute('data-header-right-width');
|
||||
root.removeAttribute('data-header-density');
|
||||
handle.removeAttribute('aria-valuenow');
|
||||
handle.removeAttribute('aria-valuemax');
|
||||
return;
|
||||
}
|
||||
const { header: h, brand: b } = bounds();
|
||||
let clamped = clampWidth(wanted, h, b);
|
||||
root.setAttribute('data-header-right-width', 'set');
|
||||
// A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
root.style.setProperty('--header-right-width', clamped + 'px');
|
||||
setDensity(clamped);
|
||||
const floor = contentMinimum();
|
||||
if (clamped >= floor) break;
|
||||
clamped = floor;
|
||||
}
|
||||
handle.setAttribute('aria-valuenow', String(clamped));
|
||||
handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b)));
|
||||
}
|
||||
|
||||
/** Several changes in one frame (a chip refill, a resize) settle once. */
|
||||
let settleFrame = 0;
|
||||
function scheduleSettle() {
|
||||
if (wanted === null || settleFrame) return;
|
||||
settleFrame = requestAnimationFrame(() => {
|
||||
settleFrame = 0;
|
||||
settle();
|
||||
});
|
||||
}
|
||||
|
||||
/** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */
|
||||
function relayout() {
|
||||
window.app?.updateTabOverflowMode?.();
|
||||
window.app?.updateConnectionLines?.();
|
||||
}
|
||||
|
||||
function apply(width) {
|
||||
wanted = width === null ? null : Math.max(MIN_WIDTH, Math.round(width));
|
||||
writeStored(wanted);
|
||||
settle();
|
||||
relayout();
|
||||
}
|
||||
|
||||
// Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row
|
||||
// can start reporting later, a header button can be switched on): re-settle when any child changes size.
|
||||
const resizeObserver = new ResizeObserver(scheduleSettle);
|
||||
const watch = () => {
|
||||
for (const child of right.children) if (child !== handle) resizeObserver.observe(child);
|
||||
};
|
||||
watch();
|
||||
new MutationObserver(() => {
|
||||
watch();
|
||||
scheduleSettle();
|
||||
}).observe(right, { childList: true });
|
||||
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
document.body.classList.add('header-divider-dragging');
|
||||
const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0;
|
||||
let moveFrame = 0;
|
||||
let lastX = event.clientX;
|
||||
const onMove = (e) => {
|
||||
lastX = e.clientX;
|
||||
if (moveFrame) return;
|
||||
moveFrame = requestAnimationFrame(() => {
|
||||
moveFrame = 0;
|
||||
wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight);
|
||||
settle();
|
||||
});
|
||||
};
|
||||
const finish = () => {
|
||||
handle.removeEventListener('pointermove', onMove);
|
||||
handle.removeEventListener('pointerup', finish);
|
||||
handle.removeEventListener('pointercancel', finish);
|
||||
handle.removeEventListener('lostpointercapture', finish);
|
||||
if (moveFrame) cancelAnimationFrame(moveFrame);
|
||||
moveFrame = 0;
|
||||
document.body.classList.remove('header-divider-dragging');
|
||||
if (wanted !== null) apply(wanted);
|
||||
};
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', finish);
|
||||
handle.addEventListener('pointercancel', finish);
|
||||
handle.addEventListener('lostpointercapture', finish);
|
||||
});
|
||||
|
||||
handle.addEventListener('dblclick', () => apply(null));
|
||||
|
||||
handle.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Home') {
|
||||
apply(null);
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
// Step from the width on screen, not the request, so a key held past the floor never leaves a dead zone.
|
||||
apply(current() + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', scheduleSettle);
|
||||
settle();
|
||||
}
|
||||
|
||||
window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM };
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
+270
-10
@@ -86,6 +86,17 @@
|
||||
'Web tab settings': '网页标签设置',
|
||||
'New group': '新建分组',
|
||||
'Rename group': '重命名分组',
|
||||
'Rename case': '重命名案例',
|
||||
Rename: '重命名',
|
||||
'New name': '新名称',
|
||||
'letters, numbers, - and _': '字母、数字、- 和 _',
|
||||
'Also rename the folder on disk': '同时重命名磁盘上的文件夹',
|
||||
'A case under the cases folder is renamed together with its folder.':
|
||||
'案例文件夹下的案例会连同其文件夹一起重命名。',
|
||||
'Stop sessions and rename': '停止会话并重命名',
|
||||
'Enter a different name.': '请输入不同的名称。',
|
||||
'Use only letters, numbers, hyphens and underscores.': '只能使用字母、数字、连字符和下划线。',
|
||||
'Failed to rename the case.': '重命名案例失败。',
|
||||
'Move group up': '上移分组',
|
||||
'Move group down': '下移分组',
|
||||
'Delete group': '删除分组',
|
||||
@@ -93,6 +104,23 @@
|
||||
'Move down': '下移',
|
||||
'Move to Ungrouped': '移到未分组',
|
||||
'Move to new group': '移到新分组',
|
||||
'New session': '新建会话',
|
||||
// The Cmd+K palette's new-session agent switch (panels-ui.js, its title tooltip).
|
||||
'Agent for the new session (Left/Right to change)': '新会话使用的智能体(按左/右方向键切换)',
|
||||
'New session here': '在此新建会话',
|
||||
'New session here…': '在此新建会话…',
|
||||
"New session in this tab's case": '在此标签页的案例中新建会话',
|
||||
"This tab's folder is not a case, so there is nothing to start beside it.":
|
||||
'此标签页的文件夹不是案例,无法在其旁边新建会话。',
|
||||
'A session is already starting.': '已有会话正在启动。',
|
||||
'Follow parent again': '重新跟随父会话',
|
||||
'Parent closed; placed on its own': '父会话已关闭;单独放置',
|
||||
'Parent loop; placed on its own': '父会话形成循环;单独放置',
|
||||
// The Focus section pinned at the top of the vertical rail (shortcuts to tabs).
|
||||
Focus: '焦点',
|
||||
'Add to Focus': '添加到焦点',
|
||||
'Remove from Focus': '从焦点移除',
|
||||
'Focus actions': '焦点操作',
|
||||
'Could not save tab groups.': '无法保存标签分组。',
|
||||
'Tab groups changed elsewhere; part of your edit no longer applies.':
|
||||
'标签分组已在别处更改;你的部分编辑已不再适用。',
|
||||
@@ -139,6 +167,8 @@
|
||||
Drag: '拖动',
|
||||
"a tile's header": '窗格的标题栏',
|
||||
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||
"a tile's header out of the window": '窗格的标题栏到窗口之外',
|
||||
'Open the Tile in Its Own Window (Detach Tiles)': '在独立窗口中打开窗格(分离窗格)',
|
||||
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||
'Remove Focused Tile': '移除聚焦的窗格',
|
||||
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||
@@ -156,8 +186,9 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
|
||||
'Only image files are supported': '仅支持图像文件',
|
||||
// A file of an unsupported type dropped on a tile (tile-grid.js) or the single
|
||||
// view (image-input.js).
|
||||
'Unsupported file type': '不支持的文件类型',
|
||||
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
|
||||
'No active session': '没有活动会话',
|
||||
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
|
||||
@@ -166,6 +197,11 @@
|
||||
'Failed to restore terminal size': '恢复终端尺寸失败',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
// The same with Detach Tiles on (tile-grid.js _paintTileHandle), and a
|
||||
// pop-out window's title as a handle back onto the tiles.
|
||||
'Drag to move the tile, or out of the window to open it on its own': '拖动可移动窗格,拖出窗口可在独立窗口中打开',
|
||||
'Drag out of the window to open the tile on its own': '拖出窗口可在独立窗口中打开此窗格',
|
||||
"Drag onto a Codeman window's tiles to dock this session there": '拖到 {name} 窗口的窗格上,即可将此会话停靠到那里',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
'Resize tile rows': '调整窗格行高',
|
||||
Attach: '附加',
|
||||
@@ -214,6 +250,9 @@
|
||||
'Close window': '关闭窗口',
|
||||
'Session unavailable': '会话不可用',
|
||||
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
|
||||
// A pop-out whose session a dashboard took back, when it cannot close itself (app.js _showSoloReleased).
|
||||
'Session moved': '会话已移走',
|
||||
'This session is back in a Codeman window. This one can be closed.': '此会话已回到 {name} 窗口中。可以关闭此窗口。',
|
||||
|
||||
// Welcome / quick start / common actions
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
@@ -233,6 +272,7 @@
|
||||
'Run Grok': '运行 Grok',
|
||||
'Run DeepSeek': '运行 DeepSeek',
|
||||
'Run OMP': '运行 OMP',
|
||||
'Run GitHub Copilot': '运行 GitHub Copilot',
|
||||
'Run Shell': '运行 Shell',
|
||||
'More tools': '更多工具',
|
||||
'Select AI backend': '选择 AI 后端',
|
||||
@@ -244,6 +284,50 @@
|
||||
'New or link a case…': '新建或关联案例…',
|
||||
'Case settings…': '案例设置…',
|
||||
'No cases match': '没有匹配的案例',
|
||||
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
|
||||
// phone case sheet and Close Session's worktree option). Worktree and branch names,
|
||||
// paths and git's own messages are user/git text and pass through untranslated.
|
||||
'Git worktrees…': 'Git 工作树…',
|
||||
'Git worktrees': 'Git 工作树',
|
||||
'Close git worktrees': '关闭 Git 工作树',
|
||||
Worktrees: '工作树',
|
||||
'Worktrees…': '工作树…',
|
||||
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
|
||||
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
|
||||
'Start sessions with': '启动会话所用',
|
||||
'New worktree': '新建工作树',
|
||||
Branch: '分支',
|
||||
'Start from': '起点',
|
||||
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
|
||||
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
|
||||
'Branch and start point': '分支和起点',
|
||||
"A branch, tag or commit. Default: the case's current commit (HEAD).":
|
||||
'分支、标签或提交。默认:案例当前提交(HEAD)。',
|
||||
'Create & start session': '创建并启动会话',
|
||||
'Loading worktrees…': '正在加载工作树…',
|
||||
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
|
||||
'detached HEAD': '游离 HEAD',
|
||||
'main checkout': '主检出',
|
||||
'not created by Codeman': '非 {name} 创建',
|
||||
locked: '已锁定',
|
||||
missing: '已丢失',
|
||||
Open: '打开',
|
||||
'Start a session in this worktree': '在此工作树中启动会话',
|
||||
'Remove…': '移除…',
|
||||
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
|
||||
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
|
||||
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
|
||||
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
|
||||
'Remove worktree': '移除工作树',
|
||||
'Kill & remove worktree': '终止并移除工作树',
|
||||
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
|
||||
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
|
||||
'Also delete its branch (git branch -d keeps a branch that is not merged)':
|
||||
'同时删除其分支(git branch -d 会保留未合并的分支)',
|
||||
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
|
||||
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
|
||||
'A launch is already in progress': '已有启动正在进行',
|
||||
'Codeman did not answer.': '{name} 没有响应。',
|
||||
'Add Case': '添加案例',
|
||||
'Open sessions': '打开会话',
|
||||
'Recent Sessions': '最近会话',
|
||||
@@ -272,6 +356,7 @@
|
||||
Delete: '删除',
|
||||
Remove: '移除',
|
||||
Edit: '编辑',
|
||||
Fetch: '拉取',
|
||||
Refresh: '刷新',
|
||||
Back: '返回',
|
||||
Next: '下一步',
|
||||
@@ -383,6 +468,8 @@
|
||||
'Header Displays': '顶部栏显示',
|
||||
'Font Controls': '字体控制',
|
||||
'System Stats': '系统状态',
|
||||
'GPU Stats': 'GPU 状态',
|
||||
'CPU MHz': 'CPU 频率',
|
||||
'Lifecycle Log': '生命周期日志',
|
||||
'Response Viewer': '回复查看器',
|
||||
'Attachments Button': '附件按钮',
|
||||
@@ -402,9 +489,15 @@
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
'New Session Button on Tabs': '标签页新建会话按钮',
|
||||
'A + on the tab that starts a session in the same case and group, in any installed CLI.':
|
||||
'标签页上的 + 按钮,可在同一案例和分组中,用任一已安装的 CLI 启动新会话。',
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
'Detach Tiles': '分离窗格',
|
||||
"Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.":
|
||||
'将窗格的标题栏拖出浏览器,即可在独立窗口中打开它;拖到另一个 {name} 窗口的窗格上,即可将它移到那里。弹出窗口的标题也可以拖回窗格中。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
@@ -435,12 +528,13 @@
|
||||
'Connection tile: offline': '离线',
|
||||
'Connection tile: queued': '已排队',
|
||||
'Connection tile: retry': '重连中',
|
||||
// App Settings → Bottom bar, translated as one group (the Git status rows,
|
||||
// #543's two included). Keys are the trimmed label text, without the scope tag.
|
||||
// App Settings → Bottom bar, translated as one group (the Git status rows, #543's two included, and the
|
||||
// two Git fetch rows, in the order the page lists them). Keys are the trimmed label text, without the
|
||||
// scope tag. The Session Options → Git strings are in that group's own block further down.
|
||||
'Bottom bar': '底部栏',
|
||||
'Git status': 'Git 状态',
|
||||
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.":
|
||||
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
|
||||
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.":
|
||||
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),它只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。',
|
||||
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
|
||||
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
|
||||
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
|
||||
@@ -450,8 +544,48 @@
|
||||
'Git status: git timeout': 'Git 状态:git 超时',
|
||||
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
|
||||
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
|
||||
'Git fetch on an interval': 'Git 定时拉取',
|
||||
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.':
|
||||
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
|
||||
'Git fetch: how often': 'Git 定时拉取:多久一次',
|
||||
'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.':
|
||||
'没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话(这里,或在“会话选项 → Git”)才会被拉取,共用一个文件夹的会话由一次运行拉取。',
|
||||
'Fetch git status': '拉取 Git 状态',
|
||||
'Refresh git status': '刷新 Git 状态',
|
||||
'Close git status': '关闭 Git 状态',
|
||||
// Edit history (edit-history-ui.js): settings chip, header button and panel.
|
||||
// The two row badges use scoped keys: a bare "new"/"edit" key would also
|
||||
// translate unrelated words elsewhere (the lookup is case-insensitive).
|
||||
'Edit history': '编辑历史',
|
||||
'Open edit history': '打开编辑历史',
|
||||
'Refresh edit history': '刷新编辑历史',
|
||||
'Close edit history': '关闭编辑历史',
|
||||
'Group by file': '按文件分组',
|
||||
'Main agent only': '仅主智能体',
|
||||
'Open a session to see its edits.': '打开一个会话以查看其编辑。',
|
||||
'Reading the edit history…': '正在读取编辑历史…',
|
||||
'Could not read the edit history.': '无法读取编辑历史。',
|
||||
'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。',
|
||||
'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}',
|
||||
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.':
|
||||
'尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。',
|
||||
'No edits yet.': '暂无编辑。',
|
||||
'No edits by the main agent.': '主智能体没有编辑。',
|
||||
'Load older': '加载更早的编辑',
|
||||
'Loading older edits…': '正在加载更早的编辑…',
|
||||
'Edit history badge: new': '新建',
|
||||
'Edit history badge: edit': '编辑',
|
||||
'File created': '已创建文件',
|
||||
'File edited ({tool})': '已编辑文件({tool})',
|
||||
Image: '图片',
|
||||
Subagent: '子智能体',
|
||||
'Open in the file preview': '在文件预览中打开',
|
||||
'Show what changed': '显示更改内容',
|
||||
'Hide the diff': '隐藏差异',
|
||||
'Reading the diff…': '正在读取差异…',
|
||||
'Could not read the diff.': '无法读取差异。',
|
||||
'No textual changes.': '没有文本更改。',
|
||||
'Diff cut short: it is larger than the viewer shows.': '差异已截断:内容超出查看器可显示的范围。',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
'Project Insights': '项目洞察',
|
||||
@@ -461,6 +595,9 @@
|
||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||
'Approvals Inbox': '审批收件箱',
|
||||
'Auto-name Sessions': '自动命名会话',
|
||||
'Spawned Tabs Follow Their Parent': '派生的标签跟随父会话',
|
||||
"A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.":
|
||||
'由另一个会话启动的会话会跟随父会话的标签进入其分组并随之移动,直到你手动移动它。关闭时:它出现在父会话旁边并停留在原处。',
|
||||
Approvals: '审批',
|
||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||
'No pending approvals': '没有待处理的审批',
|
||||
@@ -475,7 +612,6 @@
|
||||
Send: '发送',
|
||||
Permission: '权限',
|
||||
Question: '问题',
|
||||
Idle: '空闲',
|
||||
'Read My Mind': '读心术',
|
||||
'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示',
|
||||
'Predict my next prompt': '预测我的下一条提示',
|
||||
@@ -512,6 +648,9 @@
|
||||
'API key': 'API 密钥',
|
||||
'Optional. Left blank on edit keeps the existing key.': '可选。编辑时留空将保留现有密钥。',
|
||||
'Auth header': '认证请求头',
|
||||
'Default context length (tokens)': '默认上下文长度(token 数)',
|
||||
"Used for models whose size cannot be discovered. The model's whole window; the Claude floor check divides it by any stored slot count. Leave empty to let each CLI assume its own default.":
|
||||
'用于无法自动发现上下文大小的模型。这是模型的完整窗口;Claude 下限检查会将其除以已存储的槽位数。留空则让各个 CLI 使用其自身的默认值。',
|
||||
'Never send both — some servers hang indefinitely.': '切勿同时发送两者 — 部分服务器会因此无限期挂起。',
|
||||
'Authorization: Bearer (default)': 'Authorization: Bearer(默认)',
|
||||
'api-key header (Azure)': 'api-key 请求头(Azure)',
|
||||
@@ -521,9 +660,15 @@
|
||||
'Custom Endpoints': '自定义端点',
|
||||
'Choose a model': '选择模型',
|
||||
'Currently loaded': '当前已加载',
|
||||
'In use by a session': '会话正在使用',
|
||||
'Last used': '上次使用',
|
||||
'Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.':
|
||||
'未标记任何模型:此端点没有报告它已加载了哪个模型,你运行中的会话也没有在使用它。请选择要启动的模型。',
|
||||
'That endpoint no longer exists': '该端点已不存在',
|
||||
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
|
||||
// Model picker search box (index.html placeholder/aria-label and the no-matches line)
|
||||
'Search models': '搜索模型',
|
||||
'No models match': '没有匹配的模型',
|
||||
'Subagent Options': '子智能体选项',
|
||||
'Enable Tracking': '启用跟踪',
|
||||
'Active Tab Only': '仅活动标签页',
|
||||
@@ -580,6 +725,9 @@
|
||||
'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.':
|
||||
'终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。',
|
||||
'Local Echo': '本地回显',
|
||||
'Keep Size Under Keyboard': '键盘弹出时保持尺寸',
|
||||
"For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.":
|
||||
'对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。',
|
||||
'CJK Input': '中日韩输入',
|
||||
'Extended Keyboard Bar': '扩展键盘栏',
|
||||
'Gesture Control (beta)': '手势控制(测试版)',
|
||||
@@ -606,7 +754,16 @@
|
||||
'Agent Teams': '智能体团队',
|
||||
'Claude Model': 'Claude 模型',
|
||||
'1M Opus Context': 'Opus 100 万上下文',
|
||||
'Default session group': '默认会话分组',
|
||||
'New sessions for this case land in this group': '此案例的新会话将放入此分组',
|
||||
'Could not save the default session group.': '无法保存默认会话分组。',
|
||||
'Remote auto-reconnect': '远程自动重连',
|
||||
'Experimental agent coordination': '实验性智能体协作',
|
||||
"Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.":
|
||||
'允许智能体分页读取另一个会话的输出(每页有上限),并把提示词输入本地 Codex 输入框,确认未被改动后只按一次 Enter(即 inspect、stage 和 submit 命令)。实验性功能:这些命令及其 HTTP 路由可能在任何版本中变化。默认关闭;关闭时这些路由拒绝所有请求。',
|
||||
'Pin tab names on the CLI': '在 CLI 上固定标签名',
|
||||
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
|
||||
'以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。',
|
||||
'Thinking Effort': '思考强度',
|
||||
Low: '低',
|
||||
Medium: '中',
|
||||
@@ -663,6 +820,8 @@
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
'Open a session to attach a file': '请先打开一个会话再添加附件',
|
||||
'📎 Attach': '📎 添加附件',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
@@ -699,6 +858,16 @@
|
||||
'Session Color': '会话颜色',
|
||||
'Working Directory': '工作目录',
|
||||
'Set working directory': '设置工作目录',
|
||||
// Session Options > Session > Working folder (session-ui.js openSessionOptions).
|
||||
'Working folder': '工作目录',
|
||||
"Where this session's CLI runs, on the machine hosting Codeman.":
|
||||
'此会话的 CLI 运行所在的目录,位于运行{name}的机器上。',
|
||||
"Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine.":
|
||||
'此会话的 CLI 运行所在的目录,位于通过 SSH 连接的远程主机上,而不是本机。',
|
||||
"On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in.":
|
||||
'位于运行{name}的机器上,并挂载到此会话 CLI 运行所在的 Docker 容器中。',
|
||||
'Path copied': '路径已复制',
|
||||
'Copy failed': '复制失败',
|
||||
'Resume Conversation': '继续对话',
|
||||
'Close Session': '关闭会话',
|
||||
'Choose how to close': '选择关闭方式',
|
||||
@@ -727,6 +896,24 @@
|
||||
Memory: '内存',
|
||||
GPUs: 'GPU',
|
||||
|
||||
// Session Options → Git (the tab's blurb, its group head, its hint and its three rows in index.html, plus
|
||||
// the save toast session-ui.js raises). 'Status' is not a key here: that row's text is built at runtime
|
||||
// by updateGitFetchStatus, and 'Git' stays a technical name.
|
||||
"Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。',
|
||||
'Fetch on an interval': '按间隔拉取',
|
||||
"Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.":
|
||||
'拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何运行。克隆和工作树管理也会写入,但只在您当时要求时才运行。该窗口中的拉取按钮始终只是一次点击。',
|
||||
'Follow the app setting': '跟随应用设置',
|
||||
'Uses the app-wide switch and interval for this session. Switch it off to pick your own.':
|
||||
'为此会话使用应用级的开关和间隔。关闭它即可自行选择。',
|
||||
'Fetch this session': '拉取此会话',
|
||||
"Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.":
|
||||
'即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。',
|
||||
'How often': '多久一次',
|
||||
'30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.':
|
||||
'30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。',
|
||||
'Git fetch settings saved': 'Git 定时拉取设置已保存',
|
||||
|
||||
// Cron / lifecycle / panels
|
||||
'Cron Jobs': '定时任务',
|
||||
'+ New Job': '+ 新建任务',
|
||||
@@ -759,9 +946,21 @@
|
||||
|
||||
// Dynamic common status / toasts
|
||||
'Settings saved': '设置已保存',
|
||||
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
|
||||
"See what's new": '查看更新内容',
|
||||
// The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice);
|
||||
// each notice's title, message and link label carry their own zh-CN text and are skipped.
|
||||
'Security notice': '安全提示',
|
||||
'Settings applied': '设置已应用',
|
||||
'Save and keep Settings open': '保存并保持设置打开',
|
||||
'Settings saved locally': '设置已保存到本机',
|
||||
// Multi-user mode, a non-admin (settings-ui.js / session-ui.js): server-wide settings
|
||||
// writes are admin-only there.
|
||||
'Saved on this device. Server-wide settings can only be changed by an admin.':
|
||||
'已保存到本设备。服务器全局设置只能由管理员更改。',
|
||||
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
|
||||
// The reason a settings write gives when no answer came back (settings-ui.js _settingsWriteError).
|
||||
'the server could not be reached': '无法连接到服务器',
|
||||
'Tunnel active': '隧道已启用',
|
||||
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
|
||||
'Push notifications enabled': '推送通知已启用',
|
||||
@@ -865,6 +1064,19 @@
|
||||
'Enable Respawn': '启用重生',
|
||||
'Stop Respawn': '停止重生',
|
||||
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
|
||||
// Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts).
|
||||
'Keep the cache warm while idle': '空闲时保持缓存预热',
|
||||
"Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.":
|
||||
'在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。',
|
||||
'Keep prompt caches warm': '保持提示缓存预热',
|
||||
"Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.":
|
||||
'未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。',
|
||||
'Keep warm for at most': '最长保持预热',
|
||||
'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.':
|
||||
'会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。',
|
||||
hours: '小时',
|
||||
'Prompt-cache keep-warm enabled': '提示缓存预热已开启',
|
||||
'Prompt-cache keep-warm disabled': '提示缓存预热已关闭',
|
||||
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
|
||||
'Auto-Compact': '自动压缩',
|
||||
'Auto-Clear': '自动清空',
|
||||
@@ -942,6 +1154,12 @@
|
||||
'Source type filter': '来源类型筛选',
|
||||
'Copy content': '复制内容',
|
||||
'Edit file': '编辑文件',
|
||||
'Back (Alt+←)': '后退 (Alt+←)',
|
||||
'Forward (Alt+→)': '前进 (Alt+→)',
|
||||
'Back to the previous file': '返回上一个文件',
|
||||
'Forward to the next file': '前进到下一个文件',
|
||||
Expand: '展开',
|
||||
'Drag to resize': '拖动以调整大小',
|
||||
'Rendered markdown': '渲染 Markdown',
|
||||
'Line numbers': '行号',
|
||||
'Wrap lines': '自动换行',
|
||||
@@ -1138,8 +1356,11 @@
|
||||
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
|
||||
'Insert Newline': '插入换行',
|
||||
'Close Panels': '关闭面板',
|
||||
'File Viewer Back / Forward': '文件查看器后退 / 前进',
|
||||
'Previous / Next Session': '上一个 / 下一个会话',
|
||||
'Next Session': '下一个会话',
|
||||
'Previous Session': '上一个会话',
|
||||
'Next Session (alternate)': '下一个会话(备用)',
|
||||
'Switch to Tab N': '切换到第 N 个标签页',
|
||||
'Move Active Tab Left': '向左移动当前标签页',
|
||||
'Move Active Tab Right': '向右移动当前标签页',
|
||||
@@ -1202,10 +1423,21 @@
|
||||
[/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`],
|
||||
[/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`],
|
||||
[/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`],
|
||||
[/^Tunnel not stopped: ([\s\S]+)$/, (_m, why) => `隧道未停止:${why}`],
|
||||
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
|
||||
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
|
||||
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
||||
[
|
||||
/^Codeman updated to v([^:\s]+): (.+?)(?: \(\+(\d+) more\))?$/,
|
||||
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
|
||||
],
|
||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||
[/^Case renamed to "(.+)"$/, (_m, name) => `案例已重命名为“${name}”`],
|
||||
[/^New name for "(.+)"$/, (_m, name) => `“${name}”的新名称`],
|
||||
[
|
||||
/^These sessions have the folder open: (.+)\. They will be closed \(their tabs go away and nothing is restarted\) before the folder is renamed\.$/,
|
||||
(_m, list) => `这些会话正在使用该文件夹:${list}。重命名前将关闭它们(其标签页会消失,不会重新启动)。`,
|
||||
],
|
||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
||||
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
|
||||
@@ -1213,8 +1445,23 @@
|
||||
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
|
||||
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
|
||||
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
|
||||
// words pass through untranslated.
|
||||
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
|
||||
[
|
||||
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
|
||||
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
|
||||
],
|
||||
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
|
||||
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
|
||||
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
|
||||
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
|
||||
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
[/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`],
|
||||
[/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`],
|
||||
[
|
||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||
@@ -1245,6 +1492,20 @@
|
||||
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||
],
|
||||
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||
// A settings write the server did not store (settings-ui.js). The server's reason passes
|
||||
// through untranslated; the one the page supplies itself has its own entry.
|
||||
[
|
||||
/^Saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, why) => `已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
[
|
||||
/^(Run mode|Respawn presets) saved on this device, not on the server: ([\s\S]+)$/,
|
||||
(_m, what, why) =>
|
||||
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
|
||||
],
|
||||
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
|
||||
// notification drawer shows it. The CLI's label passes through.
|
||||
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
|
||||
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
|
||||
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
|
||||
// and where the model came from when the CLI did not report it. The harness
|
||||
@@ -1280,9 +1541,8 @@
|
||||
// The same while tiles can move, with the drag hint on a second line.
|
||||
// Anchored on the hint, so a bare state word is safe here.
|
||||
[
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
|
||||
(_m, state, duration) =>
|
||||
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\n(Drag to move the tile|Drag to move the tile, or out of the window to open it on its own|Drag out of the window to open the tile on its own)$/,
|
||||
(_m, state, duration, hint) => `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN[hint]}`,
|
||||
],
|
||||
];
|
||||
for (const [pattern, replacement] of patterns) {
|
||||
|
||||
+226
-28
@@ -14,6 +14,69 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
initImageInput() {
|
||||
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
|
||||
// sits invisibly over the button, and the browser opens the picker itself.
|
||||
//
|
||||
// Nothing here may open the picker from script. While the keyboard is up,
|
||||
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
|
||||
// re-dispatches it as a scripted click, and iOS will not open a file picker
|
||||
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
|
||||
// milliseconds after pointerdown, no picker, no error, and no real click after
|
||||
// it either because the gesture was cancelled. A native tap on the input is not
|
||||
// a button tap, so the shim leaves it alone and the click stays trusted. It also
|
||||
// anchors the picker popover to the input's own frame (a `hidden` input has
|
||||
// none, which is why the composer's picker floated loose).
|
||||
//
|
||||
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
|
||||
// so the fixed toolbar never reflows out from under the finger mid-tap.
|
||||
//
|
||||
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
|
||||
// responder to present the picker, and on DEVICE it does not hand the keyboard
|
||||
// back when the picker closes, even though focus never left the textarea.
|
||||
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
|
||||
// not take a keyboard measurement from one.) A blur-then-focus on the close
|
||||
// of the picker, cancel and change alike, restores DOM focus and still leaves
|
||||
// the keyboard down, because iOS only raises it inside a user gesture. So the
|
||||
// user taps the prompt when ready to type, which is the keyboard policy
|
||||
// everywhere else in the app.
|
||||
//
|
||||
// Do not try to hide the keyboard's exit by freezing the page's keyboard
|
||||
// state across the picker. That was tried (holdKeyboardState, reverted) to
|
||||
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
|
||||
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
|
||||
// bar strand in the MIDDLE of the screen over a dead black band, which is far
|
||||
// worse than the reflow. The page must follow the keyboard out.
|
||||
const attachBtn = document.getElementById('attachBtnMobile');
|
||||
const attachInput = document.getElementById('attachFileInput');
|
||||
if (attachBtn && attachInput) {
|
||||
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
// The button is the ACCESSIBLE control (the input is aria-hidden and not
|
||||
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
|
||||
// the input. That click is trusted, so opening the picker from it is allowed.
|
||||
attachBtn.addEventListener('click', () => attachInput.click());
|
||||
attachInput.addEventListener('click', (event) => {
|
||||
if (!this.activeSessionId) {
|
||||
event.preventDefault(); // refuse before the picker opens, not after a pick
|
||||
this.showToast('Open a session to attach a file', 'error');
|
||||
return;
|
||||
}
|
||||
// Captured before the picker opens: the session can be deleted while it is
|
||||
// up, and the pick must not land on whatever replaced it.
|
||||
this._attachSessionId = this.activeSessionId;
|
||||
});
|
||||
attachInput.addEventListener('change', () => {
|
||||
const files = Array.from(attachInput.files || []);
|
||||
// Reset so picking the same file again still fires change.
|
||||
attachInput.value = '';
|
||||
// Captured when the picker opened (see the click handler above).
|
||||
const sessionId = this._attachSessionId;
|
||||
this._attachSessionId = null;
|
||||
if (files.length > 0) {
|
||||
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop handlers on terminal container
|
||||
const container = document.getElementById('terminalContainer');
|
||||
if (!container) return;
|
||||
@@ -38,21 +101,102 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
this.showToast('Only image files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(imageFiles);
|
||||
const mediaFiles = this._admitPromptAttachments(Array.from(e.dataTransfer.files));
|
||||
if (mediaFiles.length > 0) this._uploadAndInsertImages(mediaFiles);
|
||||
});
|
||||
},
|
||||
|
||||
/** Upload the toolbar Attach selection and put the paths on the prompt. The
|
||||
* Compose dialog is deliberately not involved (see initImageInput).
|
||||
*
|
||||
* With local echo on (every phone by default) the paths go into the OVERLAY as
|
||||
* pending text rather than to the PTY. That is what makes them part of "what the
|
||||
* user typed": they paint at the prompt, anything typed after them appends in
|
||||
* order, Enter flushes the whole line, and opening Compose adopts path and prose
|
||||
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
|
||||
* instead would strand the path on the terminal while Compose took only the prose.
|
||||
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
||||
* text goes straight out. The trailing space keeps the next word off the path. */
|
||||
async _attachFromToolbar(files, sessionId) {
|
||||
const media = this._admitPromptAttachments(files);
|
||||
if (media.length === 0) return;
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
||||
if (paths.length === 0) return;
|
||||
// Deleted while the upload ran: a write would go to a dead session, and the
|
||||
// flushed maps below would gain an entry whose cleanup has already run.
|
||||
if (this.sessions && !this.sessions.has(sessionId)) return;
|
||||
const text = paths.join(' ');
|
||||
|
||||
// The prompt composer, if it opened for this session while the upload ran, is
|
||||
// where the prompt now lives: it took the terminal's pending text with it, so
|
||||
// appending behind it would strand the path outside the text being composed.
|
||||
// _insertComposerText adds its own separator and fires the draft save.
|
||||
const openComposer = KeyboardAccessoryBar._composerOverlay;
|
||||
const composerTextarea =
|
||||
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
|
||||
? openComposer.querySelector('.prompt-composer-textarea')
|
||||
: null;
|
||||
if (composerTextarea) {
|
||||
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
|
||||
return;
|
||||
}
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
// The overlay holds the prompt only while local echo actually owns it. A
|
||||
// session handed back to plain PTY echo by a composer nav key (echo
|
||||
// passthrough) skips the whole overlay branch in onData, Enter included, so
|
||||
// text buffered there would sit on screen and never be submitted. Nor while
|
||||
// the tile grid owns the terminal area: the main terminal is parked and
|
||||
// hidden then, and a tile's Enter never reaches its overlay, so the path
|
||||
// goes to the PTY like any other background write.
|
||||
const overlayOwnsPrompt =
|
||||
stillActive &&
|
||||
!this._tilesOwnTerminal?.() &&
|
||||
this._localEchoEnabled &&
|
||||
this._localEchoOverlay &&
|
||||
!this._echoPassthroughSessions?.has(sessionId);
|
||||
if (overlayOwnsPrompt) {
|
||||
// Prompt text the overlay has not adopted yet (a tab completion, a restored
|
||||
// tab) only lands in its flushed half once detection runs, and appendText
|
||||
// runs it AFTER we would have measured. Detect first, or the separator is
|
||||
// computed against an empty string and the path fuses onto a real word.
|
||||
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
|
||||
this._localEchoOverlay.detectBufferText();
|
||||
}
|
||||
const flushed = this._localEchoOverlay.getFlushed().text || '';
|
||||
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
|
||||
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
|
||||
this._localEchoOverlay.appendText(gap + text + ' ');
|
||||
} else {
|
||||
// No buffer to measure, so lead with a separator unconditionally rather than
|
||||
// risk fusing onto whatever the CLI's own composer already holds.
|
||||
const out = ` ${text} `;
|
||||
this._sendInputAsync(sessionId, out);
|
||||
// A background session's prompt is tracked in the flushed maps, which is what
|
||||
// a later composer open adopts and erases. An untracked write leaves that
|
||||
// count short, so the erase would eat the tail of this path instead. Not for
|
||||
// a passthrough session: its cursor can sit mid-text, nothing tracks its
|
||||
// composer, and Enter there never clears the maps, so a record would offset
|
||||
// the overlay for the next prompt instead.
|
||||
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
|
||||
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
||||
if (!this._flushedTexts) this._flushedTexts = new Map();
|
||||
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
|
||||
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||
// default to the primary pane. A second terminal (the split pane) passes its
|
||||
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||
// own. Images upload to THAT session. Plain text goes to that pane's session
|
||||
// as a Codeman-built bracketed-paste frame: the terminal is only refocused
|
||||
// and receives input(framed). onData delivers the frame; it is not written
|
||||
// into the xterm buffer.
|
||||
_handleImagePaste(target = {}) {
|
||||
const self = this;
|
||||
const terminal = target.terminal || this.terminal;
|
||||
@@ -82,17 +226,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images in clipboard items
|
||||
var imageFiles = [];
|
||||
// Check for images, videos or documents in clipboard items
|
||||
var pastedFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) pastedFiles.push(blob);
|
||||
}
|
||||
}
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
var imageFiles = self._admitPromptAttachments(pastedFiles, { textPasted: !!text });
|
||||
|
||||
// Clean up the trap
|
||||
setTimeout(function() {
|
||||
@@ -103,15 +247,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||
// has enabled bracketed-paste mode (Claude Code does). Sending text
|
||||
// via raw sendInput() strips those markers and makes pasted input
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
if (text && terminal) terminal.paste(text);
|
||||
} else if (text && terminal) {
|
||||
// No image. xterm's paste() emits CSI 200~/201~ only while its DECSET
|
||||
// 2004 mirror is on, and a terminal replay clears that mirror, so the
|
||||
// markers never arrive and a copied newline submits (#716). Build the
|
||||
// frame here and hand it to this terminal through input(). That fires
|
||||
// onData as user input (scroll to the bottom, clear the selection).
|
||||
// The main pane's bracketed-paste branch and a tile's _onTerminalData
|
||||
// deliver it, including the local-echo flush (#219). Do not also write
|
||||
// the frame from here. Markers are stripped until none is left: one pass over
|
||||
// `ESC[20` + `ESC[201~` + `1~` leaves an `ESC[201~` that would end the frame early.
|
||||
let body = text;
|
||||
while (/\x1b\[20[01]~/.test(body)) body = body.replace(/\x1b\[20[01]~/g, '');
|
||||
body = body.replace(/\r?\n/g, '\r');
|
||||
terminal.input(`\x1b[200~${body}\x1b[201~`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -120,6 +269,46 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
|
||||
* clips (the server streams those to disk under their own cap), 'document'
|
||||
* for the office and text files the server admits, else null. The one filter
|
||||
* behind Attach, drop, the Compose and paste-dialog pickers and the clipboard
|
||||
* handlers. For media the MIME type decides, but a video MIME only for the
|
||||
* containers the server admits; some Android file managers hand over a File
|
||||
* with none, so the name's extension is the fallback. A document is known by
|
||||
* its extension alone, as on the server (the MIME a file manager gives a .md
|
||||
* or .csv is anyone's guess). Mirrors the server's allowlists. */
|
||||
_promptAttachKind(file) {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
// A video MIME counts only for the five containers the server accepts on
|
||||
// ?kind=video (its own set in session-routes.ts); a 3gp or mkv falls to the
|
||||
// extension check below and is refused up front, not after a 1GB upload and a 400.
|
||||
if (/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/i.test(file.type)) return 'video';
|
||||
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
|
||||
if (['pdf', 'docx', 'pptx', 'xlsx', 'zip', 'txt', 'md', 'csv', 'tsv', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) {
|
||||
return 'document';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/** The files of a pick, drop or paste that _promptAttachKind admits, telling the
|
||||
* user about any it refuses: all of them is "Unsupported file type", some of
|
||||
* them a count of what was skipped while the rest still upload. A clipboard
|
||||
* paste that also carries text passes `textPasted`: when none of its files is
|
||||
* admitted the text is pasted instead, so that case stays quiet. */
|
||||
_admitPromptAttachments(files, { textPasted = false } = {}) {
|
||||
const admitted = files.filter((f) => this._promptAttachKind(f));
|
||||
const skipped = files.length - admitted.length;
|
||||
if (skipped > 0 && admitted.length > 0) {
|
||||
this.showToast(`${skipped} file${skipped > 1 ? 's' : ''} skipped: unsupported type`, 'info');
|
||||
} else if (skipped > 0 && !textPasted) {
|
||||
this.showToast('Unsupported file type', 'error');
|
||||
}
|
||||
return admitted;
|
||||
},
|
||||
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
@@ -136,6 +325,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return [];
|
||||
@@ -153,7 +343,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
@@ -197,11 +387,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
@@ -209,7 +399,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
||||
// A video takes the server's disk-streamed path and its larger cap; the flag
|
||||
// has to ride the URL because the size budget is fixed before the part is read.
|
||||
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
@@ -231,9 +424,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// decode/encode failure the original file is returned unchanged so the server
|
||||
// still gets a chance (and logs a precise diagnostic).
|
||||
async _normalizeImageForUpload(file) {
|
||||
if (file.type === 'image/gif') return file;
|
||||
// Only an image goes through the canvas; a video is uploaded as picked. With no
|
||||
// MIME (some Android file managers) the name's extension stands in, as it does in
|
||||
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
|
||||
const name = (file.name || '').toLowerCase();
|
||||
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
|
||||
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
|
||||
|
||||
const toPng = file.type === 'image/png';
|
||||
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
+550
-11
@@ -178,6 +178,30 @@
|
||||
</div>
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
<div class="stat-item stat-item--host" id="statGpuItem" hidden>
|
||||
<span class="stat-ring" id="statGpuRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">GPU</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statGpuBar"></div>
|
||||
</div>
|
||||
<span class="stat-value" id="statGpu">--%</span>
|
||||
</div>
|
||||
<div class="stat-item stat-item--host" id="statVramItem" hidden>
|
||||
<span class="stat-ring" id="statVramRing" aria-hidden="true"></span>
|
||||
<span class="stat-label">VRAM</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill stat-bar-cpu" id="statVramBar"></div>
|
||||
</div>
|
||||
<span class="stat-value" id="statVram">--%</span>
|
||||
</div>
|
||||
<div class="stat-item stat-item--host stat-item--ringless" id="statGpuMhzItem" hidden>
|
||||
<span class="stat-label">GPU</span>
|
||||
<span class="stat-value" id="statGpuMhz">-- MHz</span>
|
||||
</div>
|
||||
<div class="stat-item stat-item--host stat-item--ringless" id="statCpuMhzItem" hidden>
|
||||
<span class="stat-label">CPU</span>
|
||||
<span class="stat-value" id="statCpuMhz">-- MHz</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
@@ -192,12 +216,19 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<!-- Edit history of the active session (edit-history-ui.js), next to Attachments: both show
|
||||
what the session produced, and the panel opens right below. Optional and per-device
|
||||
(App Settings → Header & Panels → Header, default OFF): hidden by its marker class,
|
||||
never on phones (mobile.css). -->
|
||||
<button type="button" class="btn-icon-header btn-edit-history btn-edit-history--hidden" id="editHistoryBtn" aria-expanded="false" aria-controls="editHistoryPanel" title="Edit history" aria-label="Open edit history" onclick="app.toggleEditHistoryPanel()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude, Codex and Copilot plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -601,6 +632,7 @@
|
||||
<span class="git-status-title">Git <span class="git-status-branch" id="gitStatusBranch" data-i18n-skip></span></span>
|
||||
<div class="git-status-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshGitStatusNow()" title="Refresh" aria-label="Refresh git status">↻</button>
|
||||
<button class="btn-icon-sm" id="gitStatusFetchBtn" onclick="app.fetchGitStatusNow()" title="Fetch" aria-label="Fetch git status">⤓</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,18 +640,41 @@
|
||||
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- Edit history panel (edit-history-ui.js): every file edit the active session's agents made. -->
|
||||
<div class="edit-history-panel" id="editHistoryPanel" role="dialog" aria-label="Edit history">
|
||||
<div class="edit-history-header">
|
||||
<span class="edit-history-title">Edit history <span class="edit-history-count" id="editHistoryCount" data-i18n-skip></span></span>
|
||||
<div class="edit-history-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshEditHistory()" title="Refresh" aria-label="Refresh edit history">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-history-controls">
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryGroupToggle" onchange="app.setEditHistoryGrouped(this.checked)"><span>Group by file</span></label>
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryMainOnlyToggle" onchange="app.setEditHistoryMainOnly(this.checked)"><span>Main agent only</span></label>
|
||||
</div>
|
||||
<div class="edit-history-body" id="editHistoryBody" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Overlay -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="filePreviewBackBtn" onclick="app.stepFilePreviewHistory(-1)" title="Back (Alt+←)" aria-label="Back to the previous file" hidden disabled>←</button>
|
||||
<button class="btn-icon-sm" id="filePreviewForwardBtn" onclick="app.stepFilePreviewHistory(1)" title="Forward (Alt+→)" aria-label="Forward to the next file" hidden disabled>→</button>
|
||||
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>‹</button>
|
||||
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>›</button>
|
||||
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewPageBtn" onclick="app.toggleFilePreviewPage()" title="Rendered page" aria-label="Rendered page" aria-pressed="true" hidden>Page</button>
|
||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewExpandBtn" onclick="app.toggleFilePreviewExpanded()" title="Expand" aria-label="Expand" aria-pressed="false"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -631,6 +686,23 @@
|
||||
<button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button>
|
||||
</div>
|
||||
<div class="file-preview-footer" id="filePreviewFooter"></div>
|
||||
<div class="file-preview-resize" id="filePreviewResize" title="Drag to resize" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Grid: every previewable file of one folder (panels-ui.js openFolderGrid) -->
|
||||
<div class="folder-grid-overlay" id="folderGridOverlay">
|
||||
<div class="folder-grid-window">
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title" id="folderGridTitle" data-i18n-skip></span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm" id="folderGridUpBtn" onclick="app.folderGridUp()" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-grid-folders" id="folderGridFolders" data-i18n-skip></div>
|
||||
<div class="folder-grid-body" id="folderGridBody" data-i18n-skip></div>
|
||||
<div class="file-preview-footer" id="folderGridFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -754,9 +826,29 @@
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window for new sessions</span>
|
||||
<label class="case-default-group-label" for="caseDefaultGroup">Default session group</label>
|
||||
<select id="caseDefaultGroup" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
|
||||
<option value="">Ungrouped</option>
|
||||
</select>
|
||||
<span class="form-hint">New sessions for this case land in this group</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
<!-- Phone-only Attach (wired in image-input.js): the button opens its own file input,
|
||||
which sits invisibly over the button so iOS anchors the picker popover to it. The
|
||||
Compose dialog is deliberately not involved: it focuses its textarea, which raises
|
||||
the keyboard under the picker sheet. Sits BEFORE the mic so the two share order: 5
|
||||
and source order keeps Attach on the left. -->
|
||||
<span class="btn-attach-mobile-wrap">
|
||||
<button type="button" class="btn-attach-mobile" id="attachBtnMobile" title="Attach" aria-label="Attach">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="m21 15-5-5L5 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple tabindex="-1" aria-hidden="true">
|
||||
</span>
|
||||
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
|
||||
title="Voice input" aria-label="Start voice input" aria-pressed="false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -783,6 +875,11 @@
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window</span>
|
||||
<label class="case-default-group-label" for="caseDefaultGroupMobile">Default session group</label>
|
||||
<select id="caseDefaultGroupMobile" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)">
|
||||
<option value="">Ungrouped</option>
|
||||
</select>
|
||||
<span class="form-hint">New sessions for this case land in this group</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -860,6 +957,7 @@
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
|
||||
<div><kbd>Drag</kbd> a tile's header out of the window</div><div>Open the Tile in Its Own Window (Detach Tiles)</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
|
||||
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
|
||||
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
|
||||
@@ -886,6 +984,7 @@
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>ArrowLeft</kbd> / <kbd>Alt/Option</kbd>+<kbd>ArrowRight</kbd></div><div>File Viewer Back / Forward</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -957,6 +1056,20 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelPickHint"></p>
|
||||
<!-- Case-insensitive filter over the rendered rows (app.filterCustomModelPick):
|
||||
a display layer only, the rows themselves are never re-rendered. Reset on every
|
||||
open and focused only on non-touch devices, same in-memory rule as the rail's
|
||||
search box. -->
|
||||
<input type="search" id="customModelPickSearch" class="session-sidebar-filter-input custom-model-pick-search"
|
||||
placeholder="Search models" aria-label="Search models"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p class="form-hint custom-model-pick-no-matches" id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<!-- Shown only when no row carries a loaded/in-use/last-used mark AND the
|
||||
running-status probe answered `isLlamaSwap: false` (see
|
||||
_openCustomModelPickModal): it says out loud WHY nothing is marked, in the user's language, rather than leaving an
|
||||
alphabetical wall that looks like the question was ignored. -->
|
||||
<p class="form-hint custom-model-pick-unknown" id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p>
|
||||
<div id="customModelPickList" class="run-mode-custom-models"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1042,6 +1155,7 @@
|
||||
<option value="grok">Grok</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="omp">OMP</option>
|
||||
<option value="copilot">GitHub Copilot</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -1255,6 +1369,13 @@
|
||||
<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="M3 3v18h18"/><polyline points="7 14 11 10 14 13 20 7"/></svg>
|
||||
<span>Summary</span>
|
||||
</button>
|
||||
<!-- Git is last: the section shows for every local session, but until the interval feature is
|
||||
in use it holds one toggle and a status row that has never run, so it earns its place at the
|
||||
end of the rail rather than among the tabs a session uses every day. -->
|
||||
<button type="button" class="set-rail-item" data-tab="git" onclick="app.switchOptionsTab('git')">
|
||||
<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="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
<span>Git</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="set-rail-foot">These options belong to the selected session and save as you change them.</p>
|
||||
</nav>
|
||||
@@ -1286,6 +1407,33 @@
|
||||
<!-- Hidden wholesale for non-claude sessions and for a session with
|
||||
no live process (session-ui.js: openSessionOptions). -->
|
||||
<div id="sessionRespawnSection">
|
||||
<!-- Claude Code (v2.1.251+) reports the main conversation's prompt-cache
|
||||
state on the statusline; the exporter keeps it per session and this
|
||||
row shows it live (session-ui.js: updatePromptCacheStatus). -->
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Prompt cache</h4></div>
|
||||
<p class="set-group-hint">While the cached conversation is warm, a turn re-reads it at the cheap cached rate. Once it sits idle past the cache lifetime, the next turn re-processes the whole context at the cache-write rate.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Status</span>
|
||||
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
|
||||
<span class="set-row-desc" id="keepWarmStatus" data-i18n-skip hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts);
|
||||
the default for sessions that have not chosen lives in App Settings →
|
||||
Automation. Its status shares the Status row above: session-ui.js updateKeepWarmStatus. -->
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep the cache warm while idle</span>
|
||||
<span class="set-row-desc">Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="modalKeepWarmEnabled" onchange="app.autoSaveKeepWarm()"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Loop control</h4></div>
|
||||
<div class="set-group-body">
|
||||
@@ -1407,6 +1555,16 @@
|
||||
<input type="text" id="modalSessionName" class="set-input" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row set-row-block">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Working folder</span>
|
||||
<span class="set-row-desc" id="modalSessionPathDesc">Where this session's CLI runs, on the machine hosting Codeman.</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<code id="modalSessionPath" class="set-static mono" style="flex: 1; min-width: 0; word-break: break-all; user-select: all;"></code>
|
||||
<button type="button" class="btn-toolbar btn-sm" id="modalSessionPathCopy" onclick="app.copySessionPath()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row set-row-block">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Session color</span>
|
||||
@@ -1606,6 +1764,54 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Git ══════════════════════════════════════════════════════ -->
|
||||
<section class="set-section hidden" id="git-tab" data-label="Git">
|
||||
<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"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
<h2>Git</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Whether Codeman fetches this session's repositories on a timer.</p>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Fetch on an interval</h4></div>
|
||||
<p class="set-group-hint">Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.</p>
|
||||
<div class="set-group-body">
|
||||
<!-- Live from the session's own last run (session-ui.js: updateGitFetchStatus), which is the
|
||||
run its folder got: several sessions on one folder share one run and read the same row. -->
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Status</span>
|
||||
<span class="set-row-desc" id="gitFetchStatus" data-i18n-skip hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Follow the app setting</span>
|
||||
<span class="set-row-desc">Uses the app-wide switch and interval for this session. Switch it off to pick your own.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchFollow" checked onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="gitFetchEnabledRow">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Fetch this session</span>
|
||||
<span class="set-row-desc">Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchEnabled" onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="gitFetchIntervalRow">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">How often</span>
|
||||
<span class="set-row-desc">30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<input type="number" id="modalGitFetchInterval" class="set-num set-num-wide" value="300" min="30" max="3600" step="30" onchange="app.autoSaveGitFetch()">
|
||||
<span class="set-unit">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1631,6 +1837,18 @@
|
||||
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
|
||||
<span class="close-option-desc">Terminate the session completely</span>
|
||||
</button>
|
||||
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
|
||||
app.js): kill it, then `git worktree remove` without --force. -->
|
||||
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
|
||||
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
|
||||
<span class="close-option-title">Kill & remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
|
||||
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
|
||||
</button>
|
||||
<label class="close-option-worktree-branch">
|
||||
<input type="checkbox" id="closeConfirmWorktreeBranch">
|
||||
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer-cancel">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
|
||||
@@ -1638,6 +1856,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
|
||||
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
|
||||
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
|
||||
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
|
||||
<div class="modal-content worktree-modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
|
||||
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
|
||||
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
|
||||
<div class="worktree-list" id="worktreeList"></div>
|
||||
<div class="form-row worktree-mode-row">
|
||||
<label for="worktreeMode">Start sessions with</label>
|
||||
<select id="worktreeMode" class="form-select"></select>
|
||||
</div>
|
||||
<fieldset class="worktree-new">
|
||||
<legend>New worktree</legend>
|
||||
<div class="form-row">
|
||||
<label for="worktreeName">Name</label>
|
||||
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
|
||||
<span class="form-hint">New branch worktree-<name> from the case's current commit, unless you pick another below.</span>
|
||||
</div>
|
||||
<details class="worktree-advanced">
|
||||
<summary>Branch and start point</summary>
|
||||
<div class="form-row">
|
||||
<label for="worktreeBranch">Branch</label>
|
||||
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="worktreeBase">Start from</label>
|
||||
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
|
||||
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create & start session</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Settings Modal -->
|
||||
<div class="modal" id="appSettingsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
|
||||
@@ -1773,6 +2037,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLocalEcho"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="keyboard hold rows resize redraw mobile">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep Size Under Keyboard</span>
|
||||
<span class="set-row-desc">For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeyboardHoldsRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cjk input ime chinese japanese korean">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CJK Input</span>
|
||||
@@ -1950,11 +2221,14 @@
|
||||
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
|
||||
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="GPU 8%"><input type="checkbox" id="appSettingsShowGpuStats"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 21v-4M12 21v-4M17 21v-4M7 7V3M17 7V3"/></svg><span>GPU Stats</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="3400 MHz"><input type="checkbox" id="appSettingsShowCpuMhz"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>CPU MHz</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="4"><input type="checkbox" id="appSettingsShowResponseViewer"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Response Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-search="edit history edits changed files diffs" data-preview="header" data-preview-order="9.5"><input type="checkbox" id="appSettingsShowEditHistory"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg><span>Edit history</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
@@ -1998,6 +2272,20 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="12"><input type="checkbox" id="appSettingsShowUltracodeAgents"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg><span>Ultracode Agents</span></label>
|
||||
<label class="set-chip" data-preview="float" data-preview-order="1"><input type="checkbox" id="appSettingsUltracodeFloatingWindows"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/><circle cx="6" cy="6.5" r="0.6"/></svg><span>Ultracode Windows</span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="agent inbox messages persist restart disk codeman agent post">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep agent inbox across restarts</span>
|
||||
<span class="set-row-desc">Saves pending <code>codeman agent post</code> messages to <code>agent-inbox.json</code> in the data dir (readable by you only), so they survive a server restart. Off by default; turning it off deletes the file. Messages expire after 12 hours either way.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentInboxPersist"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="experimental agent coordination inspect stage submit codeman agent guarded input">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Experimental agent coordination</span>
|
||||
<span class="set-row-desc">Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentCoordination"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2016,7 +2304,7 @@
|
||||
<div class="set-row" data-search="git status uncommitted unpushed commit push indicator bottom bar toolbar">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git status <span class="set-scope">device</span></span>
|
||||
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.</span>
|
||||
<span class="set-row-desc">Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowGitStatus"><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -2041,6 +2329,20 @@
|
||||
</div>
|
||||
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1">
|
||||
</div>
|
||||
<div class="set-row" data-search="git fetch interval timer behind remote tracking refs poll run automatically">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git fetch on an interval <span class="set-scope">synced</span></span>
|
||||
<span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitFetchEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="git fetch how often seconds between runs interval case override session options">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Git fetch: how often <span class="set-scope">synced</span></span>
|
||||
<span class="set-row-desc">Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2233,6 +2535,20 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="new session plus add tab same case group cli">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">New Session Button on Tabs</span>
|
||||
<span class="set-row-desc">A + on the tab that starts a session in the same case and group, in any installed CLI.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabNewButton"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
|
||||
<span class="set-row-desc">Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTileDetach"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||
@@ -2247,6 +2563,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsSpawnedTabsFollowParentItem" data-search="spawned child session follow parent tab group lineage move">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawned Tabs Follow Their Parent <span class="set-tag">synced</span></span>
|
||||
<span class="set-row-desc">A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSpawnedTabsFollowParent"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
@@ -2506,6 +2829,10 @@
|
||||
<div class="set-row-text"><span class="set-row-label">Base URL</span></div>
|
||||
<input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Default context length (tokens)</span><span class="set-row-desc">Used for models whose size cannot be discovered. The model's whole window; the Claude floor check divides it by any stored slot count. Leave empty to let each CLI assume its own default.</span></div>
|
||||
<input type="number" id="customModelHostDefaultContextLength" class="set-input" min="1" max="100000000" step="1" placeholder="131072">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div>
|
||||
<input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password">
|
||||
@@ -2597,6 +2924,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="agentSkillGroup">
|
||||
<div class="set-group-head"><h4>All agent CLIs</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="agent skill codeman api workers codex gemini opencode pi grok deepseek omp antigravity">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -2633,13 +2973,6 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentTeams"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="agent skill codeman api workers">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Codeman Agent Skill</span>
|
||||
<span class="set-row-desc">Let new sessions start workers, send prompts and wait for results over the API.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Workspace Hooks</span>
|
||||
@@ -2647,6 +2980,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pin cli session name resume title tab label">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pin tab names on the CLI</span>
|
||||
<span class="set-row-desc">Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPinCliSessionName"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="remote auto reconnect ssh">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Remote auto-reconnect</span>
|
||||
@@ -2936,6 +3276,97 @@
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-group" id="discordGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="discord bot thread reply phone notification blocked">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Talk to your sessions from Discord</span>
|
||||
<span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Bot token</span>
|
||||
<span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div>
|
||||
<input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div>
|
||||
<input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div>
|
||||
<input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Create threads</span></div>
|
||||
<select id="discordThreadCreation" class="set-select">
|
||||
<option value="lazy">On the first alert</option>
|
||||
<option value="onSessionStart">For every new tab</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div>
|
||||
<select id="discordOnSessionDelete" class="set-select">
|
||||
<option value="keep">Keep the thread</option>
|
||||
<option value="archive">Archive the thread</option>
|
||||
<option value="delete">Delete the thread</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Stale threads</span><span class="set-row-desc">Threads this bot made that no tab uses any more, checked after startup and then daily. This includes threads that "When a tab is deleted" kept or archived. Threads under an hour old are skipped. Use one channel per Codeman.</span></div>
|
||||
<select id="discordStaleThreads" class="set-select">
|
||||
<option value="keep">Leave them</option>
|
||||
<option value="archive">Archive them</option>
|
||||
<option value="delete">Delete them</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">@ ping</span></div>
|
||||
<select id="discordPingMode" class="set-select">
|
||||
<option value="off">Never</option>
|
||||
<option value="critical">Critical events only</option>
|
||||
<option value="all">Every post</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tell Claude how to reach you</span>
|
||||
<span class="set-row-desc">Adds a short note about the codeman notify command to Claude's system prompt at launch, for when it is blocked on something only you can do. Nothing appears in the conversation. Local tabs only: remote and Docker tabs have no codeman command.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordStartupHint"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Which events</span>
|
||||
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
|
||||
</div>
|
||||
<select id="discordScope" class="set-select">
|
||||
<option value="attention">Needs attention</option>
|
||||
<option value="all">Everything</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">Send test saves first, then posts to the parent channel.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Voice ════════════════════════════════════════════════════ -->
|
||||
@@ -3094,6 +3525,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="configBackupGroup">
|
||||
<div class="set-group-head"><h4>Config backups</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="config backup snapshot settings clis restore retention keep">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Back up my configuration</span>
|
||||
<span class="set-row-desc">Keeps timestamped copies of your settings, CLI overrides, custom endpoints, webhook, linked cases and (in multi-user mode) accounts. A copy is made at startup and whenever one of them changes. Some of these files hold credentials, so each copy is private to this user (mode 0700/0600).</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsConfigBackup"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup folder location path directory">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Backup folder</span>
|
||||
<span class="set-row-desc">An absolute path (a leading <code>~</code> is expanded). Leave empty for the default shown. Only folders Codeman created there are ever pruned.</span>
|
||||
</div>
|
||||
<input type="text" id="appSettingsConfigBackupDir" class="set-input" placeholder="default: ~/.codeman/backups/config" data-i18n-skip>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup retention keep count snapshots">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep this many backups</span>
|
||||
<span class="set-row-desc">2 to 500 (with 1, every change would delete the state before it). The newest backup is always kept.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsConfigBackupKeepCount" oninput="this.dataset.touched=1" class="set-input" min="2" max="500" step="1" placeholder="20">
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup retention keep days age">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Delete backups older than (days)</span>
|
||||
<span class="set-row-desc">A backup is removed once a newer one has existed this many days. 0 keeps backups until the count above pushes them out.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsConfigBackupKeepDays" oninput="this.dataset.touched=1" class="set-input" min="0" max="3650" step="1" placeholder="30">
|
||||
</div>
|
||||
<div class="set-row" data-search="config backup now restore list">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Backups</span>
|
||||
<span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first. Compare shows what changed between two backups, or between a backup and the running config; credential values are never shown.</span>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button>
|
||||
</div>
|
||||
<div id="configBackupList" class="set-note" data-i18n-skip>Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Automation</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -3104,6 +3577,23 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsImageWatcherEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="prompt cache keep warm keep-alive nudge idle expire">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep prompt caches warm</span>
|
||||
<span class="set-row-desc">Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPromptCacheKeepWarm"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="prompt cache keep warm hours limit cap window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep warm for at most</span>
|
||||
<span class="set-row-desc">Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<input type="number" id="appSettingsPromptCacheKeepWarmMaxHours" class="set-num" min="0.5" max="24" step="0.5" value="2">
|
||||
<span class="set-unit">hours</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3297,7 +3787,19 @@
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()">
|
||||
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
<span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathToggleRow">
|
||||
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
|
||||
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathRow" style="display:none">
|
||||
<label>Folder</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Branch or Tag (optional)</label>
|
||||
@@ -3318,6 +3820,7 @@
|
||||
<option value="grok" data-cli="grok">Grok</option>
|
||||
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
||||
<option value="omp" data-cli="omp">OMP</option>
|
||||
<option value="copilot" data-cli="copilot">GitHub Copilot</option>
|
||||
<option value="shell">Shell (no agent)</option>
|
||||
</select>
|
||||
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
|
||||
@@ -3496,7 +3999,7 @@
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp + tmux.</span>
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp/copilot + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Network</label>
|
||||
@@ -3558,6 +4061,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rename a case (and its folder): opened by the Manage tab's pencil button, see renameCase() in session-ui.js.
|
||||
Placed after the case modal so it stacks above it. -->
|
||||
<div class="modal" id="renameCaseModal">
|
||||
<div class="modal-backdrop" onclick="app.closeRenameCase()"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Rename case</h3>
|
||||
<button class="modal-close" onclick="app.closeRenameCase()" aria-label="Cancel">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label for="renameCaseInput" id="renameCaseLabel">New name</label>
|
||||
<input type="text" id="renameCaseInput" class="set-input" maxlength="100" autocomplete="off" spellcheck="false"
|
||||
placeholder="letters, numbers, - and _" onkeydown="if (event.key === 'Enter') app.submitRenameCase()">
|
||||
</div>
|
||||
<div class="form-row hidden" id="renameCaseFolderRow">
|
||||
<label class="checkbox-row"><input type="checkbox" id="renameCaseFolder"> Also rename the folder on disk</label>
|
||||
<span class="form-hint" id="renameCaseFolderHint" data-i18n-skip></span>
|
||||
</div>
|
||||
<p class="form-hint" id="renameCaseNote">A case under the cases folder is renamed together with its folder.</p>
|
||||
<p class="form-hint hidden" id="renameCaseError" role="alert" style="color: var(--danger, #e5534b);"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-toolbar" onclick="app.closeRenameCase()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" id="renameCaseSubmit" onclick="app.submitRenameCase()">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Case Picker (Bottom Sheet) -->
|
||||
<div class="modal mobile-case-picker" id="mobileCasePickerModal">
|
||||
<div class="modal-backdrop" onclick="app.closeMobileCasePicker()"></div>
|
||||
@@ -3581,6 +4113,11 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create New Case
|
||||
</button>
|
||||
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
|
||||
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
|
||||
<span aria-hidden="true">⑂</span>
|
||||
Worktrees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4106,6 +4643,7 @@
|
||||
<script defer src="tab-layout-browser.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="header-divider.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="terminal-tile.js"></script>
|
||||
<script defer src="terminal-split.js"></script>
|
||||
@@ -4127,6 +4665,7 @@
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="edit-history-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -80,7 +80,7 @@ const PathPicker = {
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
|
||||
* title?: string, onSelect: (path: string) => void}} options
|
||||
* allowNewFolder?: boolean, title?: string, onSelect: (path: string) => void}} options
|
||||
*/
|
||||
open(options) {
|
||||
this.close(false);
|
||||
@@ -114,9 +114,15 @@ const PathPicker = {
|
||||
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
|
||||
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
|
||||
</form>
|
||||
<button type="button" class="path-picker-newfolder" title="New folder" aria-label="New folder" hidden>+📁</button>
|
||||
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<form class="path-picker-newfolder-form" hidden>
|
||||
<input type="text" class="path-picker-newfolder-name" aria-label="New folder name" placeholder="New folder name" maxlength="255" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="done">
|
||||
<button type="submit" class="path-picker-newfolder-create">Create</button>
|
||||
<button type="button" class="path-picker-newfolder-cancel">Cancel</button>
|
||||
</form>
|
||||
<div class="path-picker-toolbar">
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<label class="path-picker-sort-label">Sort
|
||||
@@ -147,6 +153,7 @@ const PathPicker = {
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
|
||||
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
|
||||
this._syncHiddenButton();
|
||||
this._wireNewFolder(overlay, !!options.allowNewFolder);
|
||||
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
|
||||
// never on each keystroke: a half-typed path is a 404 the server has to
|
||||
// answer for nothing, and jumping mid-edit would yank the field around.
|
||||
@@ -183,6 +190,70 @@ const PathPicker = {
|
||||
this.load(options.initialPath || '');
|
||||
},
|
||||
|
||||
/**
|
||||
* "New folder": opt-in per caller (`allowNewFolder`), so a picker that browses somewhere the host
|
||||
* filesystem route cannot create in (the container-workdir picker) never shows it. The folder is made
|
||||
* inside the folder being viewed and is selected once it exists.
|
||||
*/
|
||||
_wireNewFolder(overlay, enabled) {
|
||||
const button = overlay.querySelector('.path-picker-newfolder');
|
||||
const form = overlay.querySelector('.path-picker-newfolder-form');
|
||||
const input = overlay.querySelector('.path-picker-newfolder-name');
|
||||
if (!enabled) return;
|
||||
button.hidden = false;
|
||||
const close = () => {
|
||||
form.hidden = true;
|
||||
input.value = '';
|
||||
};
|
||||
button.addEventListener('click', () => {
|
||||
form.hidden = !form.hidden;
|
||||
if (!form.hidden) input.focus();
|
||||
});
|
||||
overlay.querySelector('.path-picker-newfolder-cancel').addEventListener('click', close);
|
||||
input.addEventListener('keydown', (event) => {
|
||||
// Escape closes the form first; the document handler would close the whole picker.
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
});
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const name = input.value.trim();
|
||||
if (!name || !this._currentPath) return;
|
||||
const status = overlay.querySelector('.path-picker-status');
|
||||
const create = overlay.querySelector('.path-picker-newfolder-create');
|
||||
const parent = this._currentPath;
|
||||
// One request at a time: a double tap would send a second one whose 409 shows after the first succeeded.
|
||||
create.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/filesystem/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: parent,
|
||||
name,
|
||||
sessionId: this._options?.sessionId || undefined,
|
||||
showHidden: this._showHidden ? 'true' : undefined,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result?.success) throw new Error(result?.error || 'Could not create the folder');
|
||||
if (!this.overlay) return;
|
||||
close();
|
||||
await this.load(parent);
|
||||
// The listing may have moved on while the request ran; only select it if it is still on screen.
|
||||
if (this._currentPath === parent) this.select(result.data.path);
|
||||
} catch (error) {
|
||||
if (!this.overlay) return;
|
||||
status.textContent = error.message || 'Could not create the folder';
|
||||
status.classList.add('error');
|
||||
} finally {
|
||||
create.disabled = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_loadShowHidden() {
|
||||
try {
|
||||
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
|
||||
@@ -636,16 +707,20 @@ function applyOneShotCtrl(data) {
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
|
||||
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
|
||||
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
|
||||
// an ACK, which would wedge the durable input queue. So the prompt budget is
|
||||
// that limit minus the two markers, derived here once so the refusal in
|
||||
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
|
||||
// The composer's Send, and the shell Paste dialog's, goes out as ONE
|
||||
// bracketed-paste frame on the WebSocket input path, and ws-routes.ts drops a
|
||||
// frame longer than MAX_INPUT_LENGTH (config/terminal-limits.ts: 64 KiB,
|
||||
// compared in UTF-16 code units) WITHOUT an ACK, which would wedge the durable
|
||||
// input queue. So the budget is that limit minus the two markers, derived here
|
||||
// once so the refusal in _sendBracketedPaste() and the toast that names the
|
||||
// maximum cannot drift.
|
||||
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
|
||||
const COMPOSER_PASTE_START = '\x1b[200~';
|
||||
const COMPOSER_PASTE_END = '\x1b[201~';
|
||||
|
||||
// Accessory keys that act on the terminal and leave the keyboard up.
|
||||
const ACCESSORY_REFOCUS_ACTIONS = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
@@ -664,7 +739,7 @@ const KeyboardAccessoryBar = {
|
||||
_composerDrafts: new Map(),
|
||||
_composerUploads: new Map(),
|
||||
_composerOverlay: null,
|
||||
// Longest prompt Send accepts: the input frame limit minus both markers.
|
||||
// Longest prompt or shell paste Send accepts: the input frame limit minus both markers.
|
||||
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
|
||||
@@ -801,6 +876,21 @@ const KeyboardAccessoryBar = {
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
|
||||
// A key that keeps the keyboard up must not take focus on press at all.
|
||||
// Letting the tap blur the terminal and refocusing it in the click handler
|
||||
// (kept below as the fallback) made every such key start a keyboard
|
||||
// dismissal: a blur/focus pair per tap. Same pattern as the Attach input
|
||||
// (image-input.js). A first tap on clear/compact only arms the confirm.
|
||||
this.element.addEventListener('mousedown', (e) => {
|
||||
const btn = e.target.closest('.accessory-btn');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction !== action)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.accessory-btn');
|
||||
@@ -812,8 +902,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
@@ -1205,23 +1294,39 @@ const KeyboardAccessoryBar = {
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
|
||||
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
|
||||
* its DECSET 2004 mirror after terminal replay, even though the CLI still
|
||||
* expects bracketed input, so build the byte-identical sequence directly on
|
||||
* the durable session-bound path. Enter stays a separate delayed write
|
||||
* because Codex drops keys sharing a PTY read with a bracketed paste. */
|
||||
/** Deliver one complete prompt as an explicit bracketed paste
|
||||
* (_sendBracketedPaste()), then Enter. xterm loses its DECSET 2004 mirror
|
||||
* after terminal replay, even though the CLI still expects bracketed input,
|
||||
* so the frame is built directly rather than through terminal.paste().
|
||||
* Enter stays a separate delayed write because Codex drops keys sharing a
|
||||
* PTY read with a bracketed paste. */
|
||||
_sendComposedPrompt(sessionId, text) {
|
||||
if (!this._sendBracketedPaste(sessionId, text, 'Prompt')) return false;
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** One bracketed-paste write on the durable session-bound path, the same
|
||||
* bytes a terminal paste produces, so the receiving shell or CLI takes a
|
||||
* multi-line block as one edit. Never presses Enter: callers decide.
|
||||
* `label` names the payload in the too-long toast. */
|
||||
_sendBracketedPaste(sessionId, text, label) {
|
||||
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
|
||||
// the single-line input transport and is what terminal.paste() emitted.
|
||||
const pasteText = text.replace(/\r?\n/g, '\r');
|
||||
// Paste markers inside the text are dropped first: a clipboard carrying
|
||||
// `ESC[201~` would end the bracket early and run the rest without Enter.
|
||||
// Strip until none is left: one pass over `ESC[20` + `ESC[201~` + `1~` leaves
|
||||
// an `ESC[201~` behind.
|
||||
let pasteText = text;
|
||||
while (/\x1b\[20[01]~/.test(pasteText)) pasteText = pasteText.replace(/\x1b\[20[01]~/g, '');
|
||||
pasteText = pasteText.replace(/\r?\n/g, '\r');
|
||||
if (pasteText.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return false;
|
||||
}
|
||||
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1247,7 +1352,11 @@ const KeyboardAccessoryBar = {
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
// A parked draft and text typed (or attached) on the prompt since are two
|
||||
// separate pieces of prose; one space keeps the draft's last word off the
|
||||
// prompt's first, unless either side already supplies the gap.
|
||||
const needsGap = saved && pending && !/\s$/.test(saved) && !/^\s/.test(pending);
|
||||
const initial = needsGap ? `${saved} ${pending}` : saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
@@ -1265,11 +1374,11 @@ const KeyboardAccessoryBar = {
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">🖼 Image</button>
|
||||
<button type="button" class="paste-image">📎 Attach</button>
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1285,6 +1394,10 @@ const KeyboardAccessoryBar = {
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
// Disabled alongside Send while an upload runs: this button deletes the draft
|
||||
// and closes the overlay, and the upload would then land its paths in a FRESH
|
||||
// draft, which the next open would prepend to the handed-back text.
|
||||
const terminalButton = overlay.querySelector('.prompt-composer-terminal');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
@@ -1292,6 +1405,7 @@ const KeyboardAccessoryBar = {
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
terminalButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
@@ -1317,8 +1431,9 @@ const KeyboardAccessoryBar = {
|
||||
this._syncComposerDraftIndicator();
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
// Takes files already admitted by app._admitPromptAttachments (the picker and
|
||||
// the paste below), which also toasts whatever it refused.
|
||||
const handleImages = async (images) => {
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
@@ -1332,9 +1447,11 @@ const KeyboardAccessoryBar = {
|
||||
const currentSendButton = currentOverlay?.querySelector('.paste-send');
|
||||
if (currentImageButton) {
|
||||
currentImageButton.disabled = count > 0;
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '📎 Attach';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
|
||||
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
@@ -1357,7 +1474,6 @@ const KeyboardAccessoryBar = {
|
||||
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
|
||||
else this._composerUploads.delete(sessionId);
|
||||
syncUploadUi();
|
||||
if (overlay.isConnected) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1365,10 +1481,11 @@ const KeyboardAccessoryBar = {
|
||||
textarea.addEventListener('paste', (event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
const files = Array.from(items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
// Pasted text with no admitted file lands in the textarea natively: no toast.
|
||||
const images = app._admitPromptAttachments(files, { textPasted: !!event.clipboardData.getData('text/plain') });
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
@@ -1381,8 +1498,62 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
// Reset so picking the same refused file again still fires change (and its toast).
|
||||
fileInput.value = '';
|
||||
void handleImages(app._admitPromptAttachments(files));
|
||||
});
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
// single-line draft can make the trip: the terminal prompt is one line and
|
||||
// its text reaches the PTY as typed input, where an embedded newline is a
|
||||
// key, so a multi-line draft would arrive mangled and instead stays parked
|
||||
// as a draft (the dot on the Compose key shows it).
|
||||
const text = textarea.value;
|
||||
if (!text || text.includes('\n')) {
|
||||
close({ focusTerminal: true });
|
||||
return;
|
||||
}
|
||||
// Refuse an oversized draft BEFORE it is deleted, or the text is gone from
|
||||
// both surfaces. Same cap as Send: the overlay branch lays the whole text out
|
||||
// as DOM lines on every repaint, so the transport's larger paste cap is not
|
||||
// the bound that matters here.
|
||||
if (text.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return;
|
||||
}
|
||||
// Cleared, never kept: the text now lives on the prompt, and a surviving
|
||||
// draft would be prepended to it the next time the composer opens.
|
||||
this._composerDrafts.delete(sessionId);
|
||||
close({ focusTerminal: true, preserveDraft: false });
|
||||
if (
|
||||
app._localEchoEnabled &&
|
||||
app._localEchoOverlay &&
|
||||
app.activeSessionId === sessionId &&
|
||||
!app._tilesOwnTerminal?.() &&
|
||||
!app._echoPassthroughSessions?.has(sessionId)
|
||||
) {
|
||||
// Buffer echo: the prompt is the overlay, so the text goes there unsent.
|
||||
// Passthrough is excluded deliberately: such a session skips the overlay
|
||||
// branch on Enter, so text parked there would never be submitted. The tile
|
||||
// grid is excluded for the same reason _attachFromToolbar is: the main
|
||||
// terminal is parked and hidden then, and a tile's Enter cannot flush its
|
||||
// overlay, so the text goes to the PTY below instead of stranding there.
|
||||
app._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
// Write-through echo (codex) or no local echo at all: there is no buffer
|
||||
// to park text in, because the prompt the user sees IS the CLI's own
|
||||
// composer on the PTY. Checking for the overlay alone left this branch
|
||||
// doing nothing on codex, so the button looked dead there. That composer
|
||||
// may already hold text the open adopted nothing of, so lead with a
|
||||
// separator rather than fuse onto its last word (harmless when empty).
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
app._sendInputAsync(sessionId, ` ${text}`);
|
||||
}
|
||||
this._syncComposerDraftIndicator();
|
||||
});
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
@@ -1398,29 +1569,32 @@ const KeyboardAccessoryBar = {
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
* - Image (picker): the "Image" button opens a native file picker
|
||||
* (accept=image/* → camera / photo library / files), the most reliable
|
||||
* way to attach a photo on mobile.
|
||||
* - Image (paste): if the browser exposes image blobs on the textarea's
|
||||
* - File (picker): the "Attach" button opens a native file picker
|
||||
* (accept=image/*,video/* plus the document extensions → camera / photo
|
||||
* library / files), the most reliable way to attach anything on mobile.
|
||||
* - File (paste): if the browser exposes file blobs on the textarea's
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
* on mobile, so it is a best-effort enhancement layered on the picker.
|
||||
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
|
||||
* All upload paths reuse app._uploadAndInsertImages() (image-input.js), which
|
||||
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
|
||||
pasteFromClipboard() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
// The shell this overlay was opened for: a tab switch while it is open must
|
||||
// not deliver the paste to whatever session is active at Send time.
|
||||
const sessionId = app.activeSessionId;
|
||||
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 📎 to attach a file"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-image">📎 Attach</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1429,44 +1603,54 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
// Only shell sessions reach this overlay (agent bars get Compose instead),
|
||||
// so the text lands at the prompt as one bracketed paste and is NOT run:
|
||||
// sent raw, every newline was an Enter and each line ran on its own as it
|
||||
// arrived. The user reviews the block and presses Enter themselves. A shell
|
||||
// that never enabled bracketed paste (dash, bash before 5.1) gets the
|
||||
// markers stripped by tmux, so there every line but the last still runs.
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
close();
|
||||
if (text) {
|
||||
app.sendInput(text);
|
||||
setTimeout(() => app.sendInput('\r'), 80);
|
||||
if (!text) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// A refused (too-long) paste keeps the dialog and its text for trimming.
|
||||
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
|
||||
// Filter to what the server admits (app._admitPromptAttachments toasts what it
|
||||
// refuses), close the dialog, and hand off to the shared upload+insert
|
||||
// pipeline. Returns true if any file was handled.
|
||||
const handleImages = (files, options) => {
|
||||
const images = app._admitPromptAttachments(Array.from(files || []), options);
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
|
||||
// Upload to, and insert into, the shell this overlay was opened for.
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images, { sessionId });
|
||||
return true;
|
||||
};
|
||||
|
||||
// Image picker (camera / photo library) — the reliable mobile path.
|
||||
// File picker (camera / photo library / files) — the reliable mobile path.
|
||||
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => handleImages(fileInput.files));
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
// Reset so picking the same refused file again still fires change (and its toast).
|
||||
fileInput.value = '';
|
||||
handleImages(files);
|
||||
});
|
||||
|
||||
// Best-effort: capture images pasted straight into the textarea.
|
||||
textarea.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
const files = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
handleImages(imageFiles);
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) files.push(blob);
|
||||
}
|
||||
// Pasted text with no admitted file lands in the textarea natively: no toast.
|
||||
if (handleImages(files, { textPasted: !!e.clipboardData.getData('text/plain') })) e.preventDefault();
|
||||
});
|
||||
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', close);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* toolbar/accessory bar repositioning, terminal resize on keyboard open/close,
|
||||
* and input scroll-into-view. Uses 100px threshold for iOS address bar drift.
|
||||
* - SwipeHandler — Horizontal swipe detection on the terminal area for session switching.
|
||||
* 80px minimum distance, 300ms maximum time, 100px max vertical drift.
|
||||
* 80px minimum distance, 500ms maximum time, 50px max vertical drift.
|
||||
*
|
||||
* All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE
|
||||
* reconnect (in handleInit) to prevent stale closures.
|
||||
@@ -344,6 +344,13 @@ const KeyboardHandler = {
|
||||
// branches are skipped on a shape change, whichever way the height moved.
|
||||
if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) {
|
||||
this.keyboardVisible = true;
|
||||
// Hold the PTY's rows for as long as the keyboard covers the terminal
|
||||
// (keyboardHoldsRows, keyboardClipPx in constants.js): every height
|
||||
// change makes Claude Code clear and reprint only the tail that fits.
|
||||
// Captured before anything refits, so these are the keyboard-free rows.
|
||||
if (typeof app !== 'undefined' && app.terminal && app._keyboardHoldEligible?.()) {
|
||||
app._keyboardHeldRows = app.terminal.rows;
|
||||
}
|
||||
document.body.classList.add('keyboard-visible');
|
||||
// While the keyboard is open, size the app to the visual viewport so
|
||||
// xterm's bottom row and cursor sit above the OS keyboard.
|
||||
@@ -355,6 +362,7 @@ const KeyboardHandler = {
|
||||
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
|
||||
else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) {
|
||||
this.keyboardVisible = false;
|
||||
this._releaseHeldRows();
|
||||
document.body.classList.remove('keyboard-visible');
|
||||
this.onKeyboardHide();
|
||||
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
|
||||
@@ -381,6 +389,12 @@ const KeyboardHandler = {
|
||||
// drop can re-arm the show branch against a baseline that already sits
|
||||
// at the shrunk height.
|
||||
if (shapeChanged) {
|
||||
// Held rows belong to the display that is gone: release them and arm the
|
||||
// settle, which refits and tells the PTY. Nothing else would: with the
|
||||
// keyboard up throttledResize stands aside, and a rotation alone arms no
|
||||
// settle, so xterm kept the held rows with no clip and the composer sat
|
||||
// under the keyboard until it closed.
|
||||
if (this._releaseHeldRows()) this._scheduleViewportSettle();
|
||||
this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight;
|
||||
} else if (!this.keyboardVisible) {
|
||||
this.initialViewportHeight = currentHeight;
|
||||
@@ -394,6 +408,11 @@ const KeyboardHandler = {
|
||||
this.lastViewportHeight = currentHeight;
|
||||
},
|
||||
|
||||
/** End a keyboard row hold (app._releaseKeyboardHold); true when one was held. */
|
||||
_releaseHeldRows() {
|
||||
return typeof app !== 'undefined' && !!app._releaseKeyboardHold?.();
|
||||
},
|
||||
|
||||
/** Update layout when keyboard shows/hides */
|
||||
updateLayoutForKeyboard() {
|
||||
if (!window.visualViewport) return;
|
||||
@@ -606,6 +625,9 @@ const KeyboardHandler = {
|
||||
// geometry xterm will actually keep (issue #464).
|
||||
app.syncTerminalGeometry?.();
|
||||
if (this.keyboardVisible) this._shrinkPaddingToFit();
|
||||
// Rows held: nothing re-renders on its own (the size did not change),
|
||||
// so place the screen for the settled box here.
|
||||
app._applyKeyboardClip?.();
|
||||
// Following live output → bottom, as before. Reading history → back to
|
||||
// the pre-reflow anchor instead of being yanked down (#259).
|
||||
if (shouldRestoreScroll) {
|
||||
@@ -758,14 +780,24 @@ const SwipeHandler = {
|
||||
startY: 0,
|
||||
startTime: 0,
|
||||
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
||||
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
||||
maxVerticalDrift: 100, // Max vertical movement allowed
|
||||
// 300ms rejected an unhurried but deliberate swipe outright, which read as an
|
||||
// unresponsive gesture rather than as "too slow".
|
||||
maxSwipeTime: 500, // Maximum ms for a swipe gesture
|
||||
maxVerticalDrift: 50, // Max vertical movement allowed, at any point of the gesture
|
||||
|
||||
_touchStartHandler: null,
|
||||
_touchMoveHandler: null,
|
||||
_touchEndHandler: null,
|
||||
_element: null,
|
||||
_ignoreGesture: false,
|
||||
|
||||
/**
|
||||
* Surfaces inside `.main` that own their own gestures or controls: a swipe
|
||||
* there must not switch sessions. The phone overview and welcome screen are
|
||||
* deliberately NOT listed: swiping from home into a session is a feature.
|
||||
*/
|
||||
IGNORE_SELECTOR: '.tile-grid, .term-select-bar, #cjkInput, .history-trunc-bar',
|
||||
|
||||
/** Initialize swipe handling */
|
||||
init() {
|
||||
// Only on touch devices
|
||||
@@ -776,8 +808,10 @@ const SwipeHandler = {
|
||||
|
||||
this._element = terminal;
|
||||
this._touchStartHandler = (e) => this.onTouchStart(e);
|
||||
this._touchMoveHandler = (e) => this.onTouchMove(e);
|
||||
this._touchEndHandler = (e) => this.onTouchEnd(e);
|
||||
terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true });
|
||||
terminal.addEventListener('touchmove', this._touchMoveHandler, { passive: true });
|
||||
terminal.addEventListener('touchend', this._touchEndHandler, { passive: true });
|
||||
},
|
||||
|
||||
@@ -785,9 +819,11 @@ const SwipeHandler = {
|
||||
cleanup() {
|
||||
if (this._element && this._touchStartHandler) {
|
||||
this._element.removeEventListener('touchstart', this._touchStartHandler);
|
||||
this._element.removeEventListener('touchmove', this._touchMoveHandler);
|
||||
this._element.removeEventListener('touchend', this._touchEndHandler);
|
||||
}
|
||||
this._touchStartHandler = null;
|
||||
this._touchMoveHandler = null;
|
||||
this._touchEndHandler = null;
|
||||
this._element = null;
|
||||
},
|
||||
@@ -797,20 +833,43 @@ const SwipeHandler = {
|
||||
// here. Swiping across the open session drawer — the natural "dismiss it"
|
||||
// gesture — would otherwise fire nextSession() and drop the user into a
|
||||
// session they never tapped.
|
||||
// The terminal's touch handler publishes the axis it locks for a gesture
|
||||
// that starts on it; a touch that starts elsewhere in `.main` (the home
|
||||
// screen) must not inherit the last one, or a terminal scroll a moment
|
||||
// earlier vetoes a swipe from home. Reset before any early return.
|
||||
if (typeof app !== 'undefined') app._terminalGestureAxis = null;
|
||||
this._ignoreGesture = !!e.target?.closest?.('.session-sidebar');
|
||||
if (this._ignoreGesture) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
// A second finger makes this a different gesture (pinch, two-finger scroll):
|
||||
// never measure a later lift against the first finger's start. Stays set
|
||||
// until the next touchstart, so the first finger's lift cannot clear it.
|
||||
if (!e.touches || e.touches.length !== 1) {
|
||||
this._ignoreGesture = true;
|
||||
return;
|
||||
}
|
||||
this._ignoreGesture = !!e.target?.closest?.(this.IGNORE_SELECTOR);
|
||||
this.startX = e.touches[0].clientX;
|
||||
this.startY = e.touches[0].clientY;
|
||||
this.startTime = Date.now();
|
||||
},
|
||||
|
||||
onTouchMove(e) {
|
||||
if (this._ignoreGesture || !e.touches || e.touches.length !== 1) return;
|
||||
if (Math.abs(e.touches[0].clientY - this.startY) > this.maxVerticalDrift) this._ignoreGesture = true;
|
||||
},
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (this._ignoreGesture) {
|
||||
this._ignoreGesture = false;
|
||||
return;
|
||||
}
|
||||
if (this._ignoreGesture) return;
|
||||
if (!e.changedTouches || e.changedTouches.length !== 1) return;
|
||||
if (typeof app === 'undefined') return;
|
||||
// The terminal's own touch handler (terminal-ui.js) runs first, on the same
|
||||
// gesture: a drag it locked as a scroll or a pan is not a session swipe,
|
||||
// and neither is a lift it consumed (its long-press selection gesture
|
||||
// preventDefaults the touchend, even on blank space where no selection
|
||||
// survives the lift). A swipe must not extend or drop a touch selection.
|
||||
if (e.defaultPrevented) return;
|
||||
if (app._terminalGestureAxis === 'y' || app._terminalGestureAxis === 'x') return;
|
||||
if (app._touchSelecting || app._touchSelectionActive) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
@@ -824,13 +883,10 @@ const SwipeHandler = {
|
||||
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
||||
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
||||
|
||||
// Valid swipe detected
|
||||
if (deltaX > 0) {
|
||||
// Swipe right -> previous session
|
||||
if (typeof app !== 'undefined') app.prevSession();
|
||||
} else {
|
||||
// Swipe left -> next session
|
||||
if (typeof app !== 'undefined') app.nextSession();
|
||||
}
|
||||
// Valid swipe detected. Swipe right -> previous session, left -> next.
|
||||
// In the order the strip draws, which a grouped layout ('case', 'state', a
|
||||
// sorted rail) makes differ from the canonical session order.
|
||||
if (deltaX > 0) app.prevSession({ visual: true });
|
||||
else app.nextSession({ visual: true });
|
||||
},
|
||||
};
|
||||
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
@@ -794,7 +795,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mobile-overview-row-wrap';
|
||||
wrap.appendChild(item);
|
||||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
|
||||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval, row));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -949,7 +950,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
|
||||
_buildMobileOverviewApprovalStrip(approval) {
|
||||
_buildMobileOverviewApprovalStrip(approval, row) {
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'mobile-overview-approval-strip';
|
||||
strip.setAttribute('data-i18n-skip', '');
|
||||
@@ -964,6 +965,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
strip.appendChild(btn);
|
||||
};
|
||||
// Alert-only (DeepSeek Harness / supervised hooks): open the session instead
|
||||
// of Approve/Deny that the answer route would refuse.
|
||||
if (approval.answerInTerminal) {
|
||||
addBtn('Answer in terminal', 'primary', () => {
|
||||
if (typeof this.selectSession === 'function') this.selectSession(approval.sessionId);
|
||||
else if (typeof this.openApprovalSession === 'function') this.openApprovalSession(approval.id);
|
||||
});
|
||||
return strip;
|
||||
}
|
||||
if (approval.options && approval.options.length) {
|
||||
for (const o of approval.options) {
|
||||
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
|
||||
|
||||
+152
-7
@@ -586,6 +586,8 @@ html.mobile-init .file-browser-panel {
|
||||
.btn-icon-header.btn-away-digest,
|
||||
.btn-icon-header.btn-session-manager,
|
||||
.btn-icon-header.btn-file-viewer,
|
||||
/* Edit history: a desktop panel; phones never show the button (opt-in, default OFF anyway). */
|
||||
.btn-icon-header.btn-edit-history,
|
||||
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
|
||||
(inline approve/deny in mobile-overview.js); the bell would only crowd the
|
||||
header it was designed to stay out of. */
|
||||
@@ -614,7 +616,9 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Mobile voice input button in toolbar — initially hidden via inline style,
|
||||
shown by VoiceInput._showButtons() clearing the inline display */
|
||||
.btn-voice-mobile {
|
||||
/* The Attach button shares the mic's box; its toolbar slot is on its wrapper below. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -626,15 +630,48 @@ html.mobile-init .file-browser-panel {
|
||||
color: #93c5fd;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
order: 5; /* Between Run Shell and settings gear */
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg {
|
||||
/* Toolbar slot for both, between Run Shell and the settings gear. Attach's
|
||||
WRAPPER takes it (the button inside is not a toolbar flex item), and at an
|
||||
equal order source order keeps Attach on the left of the mic. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile-wrap {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
/* Relative box for the invisible file input that sits over the button and TAKES
|
||||
THE TAPS (see initImageInput): a native tap on the input is the only thing that
|
||||
opens the picker while the keyboard is up, and iOS anchors the picker popover to
|
||||
the input's own frame. overflow: hidden is load-bearing: WebKit does not clip a
|
||||
file input, so its "Choose Files" content (~115px in this 26px box) spilled out
|
||||
invisibly over the mic and took its taps. Chromium clips it, so only iOS showed it. */
|
||||
.btn-attach-mobile-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-attach-mobile-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg,
|
||||
.btn-attach-mobile svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.btn-voice-mobile:active {
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile:active,
|
||||
/* The input above the button swallows the press, so the wrapper is what goes
|
||||
:active. Without this the only phone button with no press feedback is Attach. */
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: #2a3a5f;
|
||||
}
|
||||
|
||||
@@ -914,6 +951,29 @@ html.mobile-init .file-browser-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The "+" is opt-in (showTabNewButton, per-device; html.tabs-show-new).
|
||||
Switched on it joins gear + close on the active tab only, and the label
|
||||
reserve above grows to clear all three (test/mobile-tab-tap-zones). */
|
||||
.session-tab .tab-new {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.tabs-show-new .session-tab.active .tab-new {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
width: 28px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
html.tabs-show-new .session-tab.active .tab-name {
|
||||
min-width: 68px;
|
||||
}
|
||||
|
||||
/* Gear icon on active tab - tiny, subtle */
|
||||
.session-tab.active .tab-gear {
|
||||
display: inline-flex;
|
||||
@@ -1205,6 +1265,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(129, 140, 248, 0.5) !important;
|
||||
}
|
||||
|
||||
/* GitHub Copilot mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
|
||||
.btn-toolbar.btn-run.mode-copilot,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot {
|
||||
background: #2e1065 !important;
|
||||
border-color: rgba(163, 113, 247, 0.3) !important;
|
||||
color: #ede9fe !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-copilot:active,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot:active {
|
||||
background: #6d28d9 !important;
|
||||
border-color: rgba(163, 113, 247, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -2249,6 +2323,19 @@ html.mobile-init .file-browser-panel {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Expand and the resize grip would only shrink the full-screen window (96vw x 94vh,
|
||||
or a px size left by a drag in landscape), so a phone ignores both. */
|
||||
.file-preview-window,
|
||||
.file-preview-window.expanded {
|
||||
width: 100vw !important;
|
||||
height: var(--app-height, 100vh) !important;
|
||||
}
|
||||
|
||||
#filePreviewExpandBtn,
|
||||
.file-preview-resize {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-preview-editbar {
|
||||
padding-bottom: calc(0.4rem + var(--safe-area-bottom));
|
||||
}
|
||||
@@ -3245,13 +3332,22 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-attach-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Attach's press feedback comes from its wrapper (the file input over the button
|
||||
takes the press). Kept out of the :is() list above on purpose: a descendant
|
||||
selector there would raise that list's specificity for every button in it. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
@@ -3293,6 +3389,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-copilot, .btn-toolbar.btn-run-gear.mode-copilot) {
|
||||
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
|
||||
border-color: #5b21b6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
@@ -3350,7 +3452,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* Remove hover-dependent UI patterns */
|
||||
.session-tab .tab-close,
|
||||
.session-tab .tab-gear {
|
||||
.session-tab .tab-gear,
|
||||
.session-tab .tab-new {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
padding: 0.15rem 0.25rem;
|
||||
@@ -3372,6 +3475,47 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.subagent-dropdown-trigger:focus + .subagent-dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Press feedback for the touch bars. The pressed look is transform + filter,
|
||||
never a background or a shadow: the skin block (html:not([data-skin="og"]),
|
||||
specificity 0,2,1) and the per-run-mode colors own the background and its
|
||||
hover rules own box-shadow, so a background or shadow :active lost to them
|
||||
on every non-og skin. Nothing on these buttons sets transform or filter
|
||||
(the base .btn-toolbar:active nudge sits at lower specificity), so one rule
|
||||
reads the same on all of them. 80ms so a quick tap shows the press at all,
|
||||
on the press's own properties rather than `all`. */
|
||||
.toolbar .btn-toolbar,
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile,
|
||||
.btn-settings-mobile,
|
||||
.accessory-btn {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 80ms ease, filter 80ms ease, background-color 80ms ease, border-color 80ms ease;
|
||||
}
|
||||
|
||||
.toolbar .btn-toolbar:active,
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile,
|
||||
.btn-settings-mobile:active,
|
||||
.accessory-btn:active {
|
||||
transform: scale(0.94);
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
|
||||
/* Light skins darken on press instead of brightening (white on white reads as nothing). */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.toolbar .btn-toolbar:active, .btn-voice-mobile:active, .btn-attach-mobile-wrap:active .btn-attach-mobile, .btn-settings-mobile:active, .accessory-btn:active) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) and (prefers-reduced-motion: reduce) {
|
||||
.toolbar .btn-toolbar:active,
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile,
|
||||
.btn-settings-mobile:active,
|
||||
.accessory-btn:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
@@ -4050,6 +4194,7 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab {
|
||||
Matches the (hover: none) block above, but has to be repeated here because
|
||||
the phone block hides them on non-active tabs with (0,2,0). */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-new,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+919
-34
File diff suppressed because it is too large.
Load diff
@@ -597,8 +597,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update local cache + legacy localStorage
|
||||
this._serverRespawnPresets = custom;
|
||||
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
|
||||
// Persist to server (cross-device sync)
|
||||
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
|
||||
// Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
|
||||
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
|
||||
// (403), so they are kept as their own on this device and win over the server's on the
|
||||
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
|
||||
// role was known) marks the page read-only for the writes after it; any other failure is toasted.
|
||||
if (this._settingsReadOnly?.()) {
|
||||
this._rememberOwnSetting?.('respawnPresets', custom);
|
||||
return;
|
||||
}
|
||||
this._apiPut('/api/settings', { respawnPresets: custom })
|
||||
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
renderPresetDropdown() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "notices": [] }
|
||||
+1317
-138
File diff suppressed because it is too large.
Load diff
+1036
-68
File diff suppressed because it is too large.
Load diff
@@ -171,6 +171,17 @@ async function loadWorkbook(bytes) {
|
||||
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||
// than silently expanding again.
|
||||
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
|
||||
// ExcelJS follows a worksheet's comment, VML, table and drawing links
|
||||
// unchecked, and openpyxl spells them so the lookup finds nothing: one comment,
|
||||
// table or chart failed the whole preview. `load()` calls `this.reconcile()`
|
||||
// once on the parsed model; repair the model first (core.repairExcelJsModel).
|
||||
// Throws if a future ExcelJS renames reconcile, rather than failing again.
|
||||
const reconcile = nextWorkbook.xlsx.reconcile;
|
||||
if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable');
|
||||
nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) {
|
||||
core.repairExcelJsModel(model);
|
||||
return reconcile.call(this, model, options);
|
||||
};
|
||||
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||
// object (a whole-column dropdown is a million), and the preview never shows
|
||||
// validations, so they are not parsed at all. `maxRows` is a per-sheet
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||
const SPREADSHEET_ASSET_VERSION = 'fd81c01bef7b';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
|
||||
@@ -472,6 +472,115 @@
|
||||
return zipApi.zipSync(admission.entries, { level: 0 });
|
||||
}
|
||||
|
||||
// ExcelJS (4.4.0, lib/xlsx/xlsx.js + xform/sheet/worksheet-xform.js `reconcile`)
|
||||
// links a worksheet to its comments, comment notes (VML), tables and drawing by
|
||||
// looking the relationship Target up VERBATIM in maps keyed by its OWN spelling
|
||||
// (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`,
|
||||
// `drawing1`), then dereferences the result unchecked. openpyxl, which most
|
||||
// agents write spreadsheets with, uses absolute Targets, its own part names
|
||||
// (`/xl/comments/comment1.xml`) and a drawing XML ExcelJS parses to nothing, so
|
||||
// a workbook with ONE comment, table or chart threw ("reading 'comments'",
|
||||
// "'name'", "'anchors'") before a single cell was shown.
|
||||
//
|
||||
// This runs on ExcelJS's parsed model right before that reconcile. It only
|
||||
// re-points a link to the key ExcelJS itself stored for the same part, or drops
|
||||
// a link (or a drawing) that would dereference nothing; nothing is added. The
|
||||
// preview shows none of these parts. Lookups are own-property only, so a Target
|
||||
// or rId such as `__proto__` cannot resolve through Object.prototype.
|
||||
const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
|
||||
const REL_COMMENTS = `${REL_TYPE}comments`;
|
||||
const REL_VML_DRAWING = `${REL_TYPE}vmlDrawing`;
|
||||
const REL_TABLE = `${REL_TYPE}table`;
|
||||
const EXCELJS_DRAWING_TARGET = /\/drawings\/([a-zA-Z0-9]+)[.][a-zA-Z]{3,4}$/;
|
||||
|
||||
function ownValue(map, key) {
|
||||
return map && typeof key === 'string' && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
|
||||
}
|
||||
|
||||
// The key ExcelJS gives a part it loaded from `xl/<path>` is `../<path>`
|
||||
// (relative to xl/worksheets/). A Target that names the same part another way
|
||||
// (`/xl/tables/table1.xml`) is rewritten to that key.
|
||||
function worksheetRelativeTarget(target) {
|
||||
const path = excelJsEntryName(target[0] === '/' ? target : `xl/worksheets/${target}`);
|
||||
return path.startsWith('xl/') ? `../${path.slice(3)}` : null;
|
||||
}
|
||||
|
||||
function resolveTarget(rel, map, usable) {
|
||||
if (typeof rel.Target !== 'string') return false;
|
||||
if (usable(ownValue(map, rel.Target))) return true;
|
||||
const key = worksheetRelativeTarget(rel.Target);
|
||||
if (key && usable(ownValue(map, key))) {
|
||||
rel.Target = key;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function repairExcelJsModel(model) {
|
||||
if (!model || typeof model !== 'object') return model;
|
||||
const isObject = (value) => Boolean(value) && typeof value === 'object';
|
||||
const drawings = isObject(model.drawings) ? model.drawings : {};
|
||||
// ExcelJS reconciles every drawing that has rels and reads `.anchors` off it.
|
||||
for (const name of Object.keys(drawings)) {
|
||||
if (!isObject(drawings[name]) || !Array.isArray(drawings[name].anchors)) {
|
||||
delete drawings[name];
|
||||
if (isObject(model.drawingRels)) delete model.drawingRels[name];
|
||||
}
|
||||
}
|
||||
// ExcelJS also reconciles every table it parsed, so one it parsed to nothing goes.
|
||||
if (isObject(model.tables)) {
|
||||
for (const key of Object.keys(model.tables)) if (!isObject(model.tables[key])) delete model.tables[key];
|
||||
}
|
||||
const worksheetRels = isObject(model.worksheetRels) ? model.worksheetRels : {};
|
||||
// Worksheets can share a sheetNo (ExcelJS's worksheet pattern is unanchored),
|
||||
// and then they share one rels list: filter and index each list ONCE, or a
|
||||
// long list costs once per worksheet.
|
||||
const relsBySheetNo = new Map();
|
||||
for (const worksheet of Array.isArray(model.worksheets) ? model.worksheets : []) {
|
||||
if (!isObject(worksheet)) continue;
|
||||
const sheetNo = String(worksheet.sheetNo);
|
||||
let byId = relsBySheetNo.get(sheetNo);
|
||||
if (!byId) {
|
||||
const list = ownValue(worksheetRels, sheetNo);
|
||||
const rels = Array.isArray(list) ? list : [];
|
||||
const kept = rels.filter((rel) => {
|
||||
if (!isObject(rel)) return false;
|
||||
if (rel.Type === REL_COMMENTS) return resolveTarget(rel, model.comments, isObject);
|
||||
if (rel.Type === REL_VML_DRAWING) {
|
||||
return resolveTarget(rel, model.vmlDrawings, (vml) => isObject(vml) && Array.isArray(vml.comments));
|
||||
}
|
||||
if (rel.Type === REL_TABLE) return resolveTarget(rel, model.tables, isObject);
|
||||
return true;
|
||||
});
|
||||
if (Array.isArray(list)) worksheetRels[sheetNo] = kept;
|
||||
byId = new Map();
|
||||
for (const rel of kept) byId.set(rel.Id, rel);
|
||||
relsBySheetNo.set(sheetNo, byId);
|
||||
}
|
||||
// `<tablePart r:id>`, `<drawing r:id>` and `<hyperlink r:id>` are followed
|
||||
// through the rels unchecked, so each must name a rel that is still there.
|
||||
if (Array.isArray(worksheet.tables)) {
|
||||
worksheet.tables = worksheet.tables.filter((part) => {
|
||||
const rel = isObject(part) ? byId.get(part.rId) : undefined;
|
||||
return Boolean(rel) && rel.Type === REL_TABLE;
|
||||
});
|
||||
}
|
||||
if (isObject(worksheet.drawing)) {
|
||||
const rel = byId.get(worksheet.drawing.rId);
|
||||
const match = rel && typeof rel.Target === 'string' ? rel.Target.match(EXCELJS_DRAWING_TARGET) : null;
|
||||
if (!rel || typeof rel.Target !== 'string' || (match && !ownValue(drawings, match[1]))) {
|
||||
worksheet.drawing = undefined;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(worksheet.hyperlinks)) {
|
||||
worksheet.hyperlinks = worksheet.hyperlinks.filter(
|
||||
(link) => isObject(link) && (!link.rId || byId.has(link.rId))
|
||||
);
|
||||
}
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function parseCellRef(ref) {
|
||||
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
|
||||
if (!match) return null;
|
||||
@@ -983,6 +1092,7 @@
|
||||
createXmlCounter,
|
||||
admitXlsx,
|
||||
buildAdmittedArchive,
|
||||
repairExcelJsModel,
|
||||
excelJsEntryName,
|
||||
parseCellRef,
|
||||
parseRange,
|
||||
|
||||
+1608
-9
File diff suppressed because it is too large.
Load diff
@@ -101,6 +101,10 @@ self.addEventListener('fetch', (event) => {
|
||||
if (request.headers.get('upgrade') === 'websocket') return;
|
||||
if (request.headers.get('accept') === 'text/event-stream') return;
|
||||
if (request.url.includes('/api/')) return;
|
||||
// A rendered HTML page is served `no-store` behind a capability that logout
|
||||
// revokes; caching a popped-out page here would let the offline fallback
|
||||
// serve it after the capability is gone.
|
||||
if (request.url.includes('/html-view/')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||
* read degrades to the flat rail with a capped, backed-off retry.
|
||||
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
|
||||
* row) applied optimistically and saved through ONE serialized
|
||||
* row, pin a row to Focus) applied optimistically and saved through ONE serialized
|
||||
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
|
||||
* version conflict.
|
||||
*
|
||||
@@ -32,17 +32,26 @@
|
||||
'use strict';
|
||||
|
||||
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
|
||||
// The Focus section's collapse, per device. Its own key, not an entry in the
|
||||
// group list above: that list is garbage-collected against the layout's group
|
||||
// ids, and Focus is not a group.
|
||||
const FOCUS_COLLAPSED_STORAGE_KEY = 'codeman:tab-focus-collapsed';
|
||||
|
||||
const refKey = (ref) => `${ref.kind}:${ref.id}`;
|
||||
const validRef = (ref) =>
|
||||
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||
const stableIds = (value) => [...new Set(asIds(value))];
|
||||
// `placement: 'manual'` must survive the round trip: the browser writes whole
|
||||
// layouts back, and dropping it would re-attach a hand-placed child session to
|
||||
// its parent's subtree on the next save.
|
||||
const copyRef = (r) =>
|
||||
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
|
||||
// `placement: 'manual'` and `focus: true` must survive the round trip: the
|
||||
// browser writes whole layouts back, so a field dropped here is erased on the
|
||||
// next save, by whoever saves. Losing placement re-attaches a hand-placed child
|
||||
// session to its parent's subtree; losing focus unpins every Focus shortcut.
|
||||
const copyRef = (r) => {
|
||||
const copy = { kind: r.kind, id: r.id };
|
||||
if (r.placement === 'manual') copy.placement = 'manual';
|
||||
if (r.focus === true) copy.focus = true;
|
||||
return copy;
|
||||
};
|
||||
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
|
||||
|
||||
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
|
||||
@@ -108,6 +117,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Focus section's per-device collapse. `ok: false` means the store threw;
|
||||
* any value other than 'true' reads as expanded.
|
||||
*/
|
||||
function loadFocusCollapsed(storage) {
|
||||
try {
|
||||
return { collapsed: storage.getItem(FOCUS_COLLAPSED_STORAGE_KEY) === 'true', ok: true };
|
||||
} catch (_error) {
|
||||
return { collapsed: false, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function saveFocusCollapsed(storage, collapsed) {
|
||||
try {
|
||||
storage.setItem(FOCUS_COLLAPSED_STORAGE_KEY, collapsed === true ? 'true' : 'false');
|
||||
return { collapsed: collapsed === true, ok: true };
|
||||
} catch (_error) {
|
||||
return { collapsed: false, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refs pinned to the Focus section that this browser can draw: focused,
|
||||
* a live session or an open web tab, each once, in stored order (groups in
|
||||
* order, then Ungrouped). Works with or without named groups, so Focus also
|
||||
* shows above the flat rail. A ref whose session closed or whose web tab is
|
||||
* not open here simply drops out; the server prunes deleted refs on its own.
|
||||
*/
|
||||
function focusRefs(layoutInput, options = {}) {
|
||||
if (!layoutInput) return [];
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
const live = new Set(asIds(options.liveSessionIds));
|
||||
const open = new Set(asIds(options.openWebviewIds));
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const ref of [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]) {
|
||||
if (ref.focus !== true) continue;
|
||||
if (!(ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id))) continue;
|
||||
const key = refKey(ref);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({ kind: ref.kind, id: ref.id });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function saveCollapsedGroupIds(storage, groupIds) {
|
||||
const ids = stableIds(groupIds);
|
||||
try {
|
||||
@@ -131,11 +186,19 @@
|
||||
* tab, else the active session), so selecting a hidden session by keyboard,
|
||||
* palette or Alt+N never leaves the user with no visible selection.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
|
||||
* `options.parents` (child session id -> parent session id) places a live
|
||||
* session the layout has not stored yet right after its parent's rows when
|
||||
* the parent IS stored: that is where the server puts a child that follows its
|
||||
* parent, so a spawned session appears in its parent's group at once instead
|
||||
* of jumping there from Ungrouped once the layout re-read lands.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs }}
|
||||
* null when the layout has no groups: the caller renders the flat rail
|
||||
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
|
||||
* collapse hides (`hidden`); `sectionByRef` maps every placed row
|
||||
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
|
||||
* `focusRefs` is focusRefs() for the same options: shortcuts, never rows,
|
||||
* so they are in none of the other fields.
|
||||
*/
|
||||
function project(layoutInput, options = {}) {
|
||||
if (!layoutInput) return null;
|
||||
@@ -152,6 +215,7 @@
|
||||
? `session:${options.activeSessionId}`
|
||||
: '';
|
||||
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
|
||||
const withFollowers = followingChildren(layout, liveSessionIds, options.parents);
|
||||
const placed = new Set();
|
||||
const visibleRefs = [];
|
||||
const hiddenTabGroupByRef = {};
|
||||
@@ -182,14 +246,14 @@
|
||||
|
||||
for (const group of layout.groups) {
|
||||
const isCollapsed = collapsed.has(group.id);
|
||||
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
|
||||
const { shown, hidden, count } = place(withFollowers(group.refs), group.id, isCollapsed);
|
||||
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
|
||||
}
|
||||
const omissions = [
|
||||
...liveSessionIds.map((id) => ({ kind: 'session', id })),
|
||||
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
|
||||
];
|
||||
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
|
||||
const ungrouped = place([...withFollowers(layout.ungrouped), ...omissions], null, false);
|
||||
if (ungrouped.count > 0) {
|
||||
sections.push({
|
||||
id: null,
|
||||
@@ -200,7 +264,7 @@
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) };
|
||||
}
|
||||
|
||||
const ALERT_RANK = { action: 2, idle: 1 };
|
||||
@@ -227,6 +291,47 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* For project(): returns `expand(refs)`, which inserts each live, not-yet-stored
|
||||
* child session right after its parent's rows in the parent's container. A
|
||||
* child of such a child follows it the same way. Without `parents` it is the
|
||||
* identity, so callers that pass no lineage see no change.
|
||||
*/
|
||||
function followingChildren(layout, liveSessionIds, parents) {
|
||||
if (!parents || typeof parents !== 'object') return (refs) => refs;
|
||||
const stored = new Set(refLocations(layout).map((item) => refKey(item.ref)));
|
||||
const accepted = new Set();
|
||||
const pending = new Map();
|
||||
// Accept parents before their children (bounded: one pass per accepted child).
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const id of liveSessionIds) {
|
||||
const key = `session:${id}`;
|
||||
const parentId = parents[id];
|
||||
if (stored.has(key) || accepted.has(key) || typeof parentId !== 'string' || parentId === id) continue;
|
||||
const parentKey = `session:${parentId}`;
|
||||
if (!stored.has(parentKey) && !accepted.has(parentKey)) continue;
|
||||
accepted.add(key);
|
||||
if (!pending.has(parentKey)) pending.set(parentKey, []);
|
||||
pending.get(parentKey).push({ kind: 'session', id });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!pending.size) return (refs) => refs;
|
||||
return (refs) => {
|
||||
const out = [...refs];
|
||||
for (const [parentKey, children] of pending) {
|
||||
const block = lineageBlock(layout, { kind: 'session', id: parentKey.slice(8) }, parents);
|
||||
let at = -1;
|
||||
out.forEach((ref, index) => {
|
||||
if (block.has(refKey(ref))) at = index;
|
||||
});
|
||||
if (at >= 0) out.splice(at + 1, 0, ...children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
|
||||
* where, the headers' names, what a collapse hides), as opposed to a row's own
|
||||
@@ -418,6 +523,35 @@
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a child session is placed, for the row menu. null for a session with no
|
||||
* parent (or not stored). Otherwise `{ state, parentId, canFollow }`:
|
||||
* - inherited: it follows its parent (moves with it).
|
||||
* - manual: placed by hand; "Follow parent again" puts it back.
|
||||
* - dangling: its parent is not in this layout (closed, or another owner's),
|
||||
* so it stands on its own; nothing re-adopts it automatically.
|
||||
* - cycle: the parent chain loops back to it; it stands on its own.
|
||||
*/
|
||||
function placementState(layoutInput, ref, parents) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
if (!validRef(ref) || ref.kind !== 'session') return null;
|
||||
const parentId = parents?.[ref.id];
|
||||
if (typeof parentId !== 'string' || !parentId || parentId === ref.id) return null;
|
||||
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
|
||||
const own = stored.get(refKey(ref));
|
||||
if (!own) return null;
|
||||
if (!stored.has(`session:${parentId}`)) return { state: 'dangling', parentId, canFollow: false };
|
||||
// Walk up the stored ancestors; meeting this session again is a loop.
|
||||
const seen = new Set([ref.id]);
|
||||
for (let id = parentId; id && stored.has(`session:${id}`); id = parents[id]) {
|
||||
if (seen.has(id)) return { state: 'cycle', parentId, canFollow: false };
|
||||
seen.add(id);
|
||||
}
|
||||
return own.placement === 'manual'
|
||||
? { state: 'manual', parentId, canFollow: true }
|
||||
: { state: 'inherited', parentId, canFollow: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a moved row lands, as the server's `index` (counted AFTER the moved
|
||||
* block is taken out): before or after `anchor` in that container, or at its
|
||||
@@ -526,6 +660,17 @@
|
||||
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
|
||||
return layout;
|
||||
}
|
||||
case 'setFocus': {
|
||||
// Pin or unpin one row. It never moves: Focus draws a shortcut, and the
|
||||
// row stays in its group. Idempotent, so a replay after a 409 is safe.
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
if (typeof op.focused !== 'boolean') editError('invalid Focus state');
|
||||
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(op.ref));
|
||||
if (!location) editError('unknown row');
|
||||
if (op.focused) location.ref.focus = true;
|
||||
else delete location.ref.focus;
|
||||
return layout;
|
||||
}
|
||||
case 'moveRef': {
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
const targetKey = refKey(op.ref);
|
||||
@@ -546,6 +691,43 @@
|
||||
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
|
||||
return layout;
|
||||
}
|
||||
case 'followParent': {
|
||||
// Clear a hand placement: the child (with the sessions that follow it)
|
||||
// goes back after its parent's rows. The server re-derives the exact
|
||||
// order on save; this only has to put the row in the right group now.
|
||||
const state = placementState(layout, op.ref, op.parents);
|
||||
if (!state || state.state === 'dangling' || state.state === 'cycle') editError('no parent to follow');
|
||||
if (state.state !== 'manual') return layout;
|
||||
const targetKey = refKey(op.ref);
|
||||
// Drop the placement only: a Focus pin stays (the whole layout is PUT,
|
||||
// so a field left off here would be erased on save).
|
||||
const clear = (ref) => {
|
||||
if (refKey(ref) !== targetKey) return ref;
|
||||
const copy = copyRef(ref);
|
||||
delete copy.placement;
|
||||
return copy;
|
||||
};
|
||||
for (const group of layout.groups) group.refs = group.refs.map(clear);
|
||||
layout.ungrouped = layout.ungrouped.map(clear);
|
||||
const childKeys = new Set();
|
||||
for (const key of lineageBlock(layout, op.ref, op.parents)) childKeys.add(key);
|
||||
const block = refLocations(layout)
|
||||
.filter((item) => childKeys.has(refKey(item.ref)))
|
||||
.map((item) => copyRef(item.ref));
|
||||
block.sort((a, b) => (refKey(a) === targetKey ? -1 : refKey(b) === targetKey ? 1 : 0));
|
||||
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !childKeys.has(refKey(ref)));
|
||||
layout.ungrouped = layout.ungrouped.filter((ref) => !childKeys.has(refKey(ref)));
|
||||
const parentRef = { kind: 'session', id: state.parentId };
|
||||
const home = refLocations(layout).find((item) => refKey(item.ref) === refKey(parentRef));
|
||||
const container = containerRefs(layout, home.groupId);
|
||||
const parentBlock = lineageBlock(layout, parentRef, op.parents);
|
||||
let at = -1;
|
||||
container.forEach((ref, index) => {
|
||||
if (parentBlock.has(refKey(ref))) at = index;
|
||||
});
|
||||
container.splice(at + 1, 0, ...block);
|
||||
return layout;
|
||||
}
|
||||
default:
|
||||
return editError(`unknown operation ${op.type}`);
|
||||
}
|
||||
@@ -739,12 +921,16 @@
|
||||
applyOperation,
|
||||
moveDestination,
|
||||
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
|
||||
placementState,
|
||||
dropOperation,
|
||||
contentKey,
|
||||
createEditCoordinator,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
focusRefs,
|
||||
loadFocusCollapsed,
|
||||
saveFocusCollapsed,
|
||||
MAX_GROUPS,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.closeTabRailActionMenu();
|
||||
this.closeTabGroupMenu?.();
|
||||
const trigger = event.currentTarget;
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'tab-rail-action-menu';
|
||||
@@ -327,15 +328,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const actions = [
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
// The row's "+" (showTabNewButton on) as a menu item: a keyboard route beside
|
||||
// Enter or Space on the "+" itself, and its only route in the compact rail,
|
||||
// which hides it. Session rows only: tiles share this menu and carry no "+".
|
||||
...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab')
|
||||
? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }]
|
||||
: []),
|
||||
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
// A tile (Detach Tiles on) opens its own size, over where it is: the
|
||||
// keyboard's way to what dragging its header out of the window does.
|
||||
...(this._tileGrid?.has?.(sessionId) && this._tileDetachAllowed?.()
|
||||
? [{ label: 'Open in a new window', run: () => this.detachTile(sessionId) }]
|
||||
: (this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||
];
|
||||
for (const action of actions) {
|
||||
// A note (a child session's placement) is plain text, not a menu item: it
|
||||
// takes no focus and no arrow stop, and describes the menu instead.
|
||||
if (action.note) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'tab-rail-action-menu-note';
|
||||
note.setAttribute('role', 'none');
|
||||
note.id = 'tabRailActionMenuNote';
|
||||
note.textContent = action.label;
|
||||
menu.setAttribute('aria-describedby', note.id);
|
||||
menu.appendChild(note);
|
||||
continue;
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.setAttribute('role', 'menuitem');
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
* that guard, so xterm returns without emitting and the committed character is
|
||||
* silently dropped.
|
||||
*
|
||||
* ⚠ Desktop fine pointers must not run this (#716). xterm already forwards the
|
||||
* composed character, and a second forward doubles a dead-key backtick.
|
||||
* `create()` returns null unless `matchMedia('(pointer: coarse)')` matches or
|
||||
* the browser is Android, so soft keyboards keep the recovery and a desktop
|
||||
* does not attach it. Android is checked by user agent because Chromium reports
|
||||
* its PRIMARY pointer as fine once a mouse, a trackpad cover or a stylus is
|
||||
* attached, while the text still comes from the soft keyboard (#441, #541 were
|
||||
* measured there); Android always ran this before #716, so the check keeps it.
|
||||
* A missing or throwing `matchMedia` stands down elsewhere: a missed recovery is
|
||||
* acceptable, a duplicated keystroke is not.
|
||||
*
|
||||
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
|
||||
* controller that looks useful while doing nothing. For a keydown that really
|
||||
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
|
||||
@@ -64,7 +75,22 @@
|
||||
return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch / coarse pointer, or any Android browser. See the file header: on a
|
||||
* desktop fine pointer this forward runs on top of xterm's own composition
|
||||
* handling.
|
||||
*/
|
||||
function isSoftKeyboardPlatform() {
|
||||
try {
|
||||
if (/Android/i.test(global.navigator?.userAgent || '')) return true;
|
||||
return global.matchMedia?.('(pointer: coarse)')?.matches === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
if (!isSoftKeyboardPlatform()) return null;
|
||||
const textarea = options?.textarea;
|
||||
const emitRecovered = options?.emitRecovered;
|
||||
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
|
||||
|
||||
+102
-51
@@ -18,20 +18,23 @@
|
||||
* 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
|
||||
* (_maybePageCliTranscript) through the primary pane's gates, plus an
|
||||
* overflow-row discount for this pane's capture-before-resize load
|
||||
* (_localRows), and only while the viewport is on the live screen (a
|
||||
* wheel-down from those overflow rows is xterm's, and brings it home).
|
||||
* - SGR wheel forwarding (_maybeForwardWheelToCli): a tracked CLI
|
||||
* (fullscreen Claude, tracked Codex) 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 and codex
|
||||
* on the alternate screen, Claude's repaint mode) leaves the xterm no
|
||||
* scrollback, so the wheel pages the CLI's own transcript with
|
||||
* PageUp/PageDown (_maybePageCliTranscript) through the primary pane's gates
|
||||
* and its pure gesture rules (CodemanTerminalInput.pageKeysForGesture), plus
|
||||
* an overflow-row discount for this pane's capture-before-resize load
|
||||
* (_localRows), only while the viewport is on the live screen (a wheel-down
|
||||
* from those overflow rows is xterm's, and brings it home), and never while
|
||||
* THIS pane's session has a dialog open.
|
||||
* - The desktop click report: a plain left-click hand-encoded as SGR while
|
||||
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
|
||||
* whose mouse DECSETs the server strips (_installClickListener), sent
|
||||
@@ -48,8 +51,8 @@
|
||||
* Android tablet, or a large foldable unfolded in landscape, reaches.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelTicksForGesture/coalesceScrollBytes/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts)
|
||||
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
*/
|
||||
@@ -204,9 +207,10 @@
|
||||
// flag, app._linkHovered, belongs to its terminal alone).
|
||||
this._linkHovered = false;
|
||||
this._onFocusIn = null;
|
||||
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
|
||||
// whole page, carried to the next wheel event.
|
||||
this._pageKeyPending = 0;
|
||||
// Hollow-buffer paging (_maybePageCliTranscript): the gesture in progress
|
||||
// (travel short of a whole page, its direction and last event time), the
|
||||
// state CodemanTerminalInput.pageKeysForGesture hands back.
|
||||
this._pageKeyGesture = null;
|
||||
// Shift+wheel travel short of a whole line, carried to the next wheel
|
||||
// event (_maybeScrollLocalOnShift).
|
||||
this._shiftScrollPending = 0;
|
||||
@@ -226,6 +230,7 @@
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this._wheelNativeStarted = false;
|
||||
const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
@@ -239,10 +244,15 @@
|
||||
scrollback: this.scrollback ?? DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
// OSC 8 hyperlinks open through the primary pane's opener (terminal-ui.js).
|
||||
linkHandler: global.app?._terminalOsc8LinkHandler?.(),
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
// OSC 52 clipboard writes from this pane's PTY, gated on THIS pane's
|
||||
// buffer load so a replay never re-copies (terminal-ui.js).
|
||||
global.app?._installOsc52Clipboard?.(this.terminal, () => this._bufferLoading);
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
@@ -321,6 +331,7 @@
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.isUserBoundShortcutEvent?.(ev)) return false;
|
||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
@@ -333,9 +344,10 @@
|
||||
if (global.app?.tileShortcutFor?.(ev)) return false;
|
||||
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
|
||||
// a pasted image uploads to this pane's session and its path is typed
|
||||
// here, and pasted text goes into this xterm with its bracketed-paste
|
||||
// markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
|
||||
// xterm's default only ever pasted text.
|
||||
// here. Plain text goes to this pane's session as a Codeman-built
|
||||
// bracketed-paste frame; this terminal is only refocused and receives
|
||||
// input(framed), which onData delivers. Mirrors terminal-ui.js's own
|
||||
// Ctrl+V gate; without it xterm's default only ever pasted text.
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
|
||||
return false;
|
||||
@@ -635,9 +647,10 @@
|
||||
// Recovered text goes straight to _onTerminalData(), never through xterm's
|
||||
// onData, so it is not counted as xterm's own (see connect()'s onData).
|
||||
// Created after terminal.open(): xterm's capture `input` listener on the
|
||||
// textarea is registered there, and must run before the controller's. No
|
||||
// device or mode gate, as in the primary pane: with a hardware keyboard it
|
||||
// costs one assignment per keydown. A failure leaves xterm's own handling.
|
||||
// textarea is registered there, and must run before the controller's.
|
||||
// create() returns null on a fine pointer (#716): the recovery is for soft
|
||||
// keyboards, and on a desktop it delivers a dead-key character twice.
|
||||
// A failure leaves xterm's own handling.
|
||||
_createKeyCode229Recovery() {
|
||||
this._destroyKeyCode229Recovery();
|
||||
if (!this.terminal) return;
|
||||
@@ -770,8 +783,15 @@
|
||||
try {
|
||||
if (this._destroyed) return;
|
||||
const shell = this.sessionMode === 'shell';
|
||||
let query = shell ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
if (this.boundedLoad && !shell) query = `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
|
||||
// A shell loads tmux's rendered history, bounded (SHELL_LOAD_QUERY,
|
||||
// constants.js), never the raw byte recording; a grid tile bounds it
|
||||
// by its own scrollback instead.
|
||||
let query = shell ? SHELL_LOAD_QUERY : 'full=1';
|
||||
if (this.boundedLoad) {
|
||||
query = shell
|
||||
? `${SHELL_LOAD_BASE_QUERY}${this._historyLinesQuery()}`
|
||||
: `full=1&tail=${TERMINAL_TAIL_SIZE}${this._historyLinesQuery()}`;
|
||||
}
|
||||
this._loadAbort = controller;
|
||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: !shell }) ?? 45000);
|
||||
let payload;
|
||||
@@ -1037,7 +1057,7 @@
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
// event it consumes, so a bubbling listener here would never see the wheel
|
||||
// while the pane still has scrollback to scroll. Not passive: the three
|
||||
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
|
||||
// routes this pane takes over, forwarding the wheel to a tracked CLI's
|
||||
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
|
||||
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
|
||||
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
|
||||
@@ -1059,11 +1079,11 @@
|
||||
|
||||
// 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,
|
||||
// Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own
|
||||
// transcripts on SGR wheel reports while cliMouseTracking is on,
|
||||
// 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
|
||||
// frames, including the CLI's pinned input box, 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
|
||||
@@ -1076,19 +1096,24 @@
|
||||
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;
|
||||
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelTicksForGesture) 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;
|
||||
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
|
||||
// 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 lines = input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
if (!lines) return true;
|
||||
if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
|
||||
const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now());
|
||||
this._wheelTickGesture = step.state;
|
||||
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
|
||||
const bytes = input.sgrWheelReports(lines, pos);
|
||||
const bytes = input.sgrWheelReports(step.ticks, pos);
|
||||
if (bytes) this._queueScrollBytes(bytes);
|
||||
return true;
|
||||
}
|
||||
@@ -1147,25 +1172,32 @@
|
||||
|
||||
// Hollow-buffer paging, the twin of the primary pane's
|
||||
// _maybePageCliTranscript (terminal-ui.js; keep the two in step). A CLI that
|
||||
// draws in place (opencode, on the alternate screen; Claude's repaint mode)
|
||||
// leaves this xterm no scrollback, so a wheel scrolled nothing; instead the
|
||||
// travel pages the CLI's own transcript with PageUp/PageDown. Every gate is
|
||||
// the primary pane's own, asked for THIS pane (its terminal, its session,
|
||||
// never the active one), so the CLI rules stay in terminal-ui.js and this
|
||||
// file names no CLI. Returns true when the wheel was consumed here.
|
||||
// draws in place (on the alternate screen, or Claude's repaint mode) leaves
|
||||
// this xterm no scrollback, so a wheel scrolled nothing; instead the travel
|
||||
// pages the CLI's own transcript with PageUp/PageDown. Every gate is the
|
||||
// primary pane's own, asked for THIS pane (its terminal, its session, never
|
||||
// the active one), so the CLI rules stay in terminal-ui.js and this file
|
||||
// names no CLI. The gesture rules (first-event page, notch debt,
|
||||
// pinch and sideways-swipe consume) are the shared pure
|
||||
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
|
||||
// The primary pane's unloaded-scrollback fall-through (`paneHistoryLines`
|
||||
// > 0 with baseY 0, after a restart's tail replay) has no twin here: a tile
|
||||
// loads tmux's rendered history (`full=1`), so it does not share that
|
||||
// tail-select failure, and its non-shell wheel has no pull to fall through to.
|
||||
// Returns true when the wheel was consumed here.
|
||||
_maybePageCliTranscript(ev) {
|
||||
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
|
||||
const app = global.app;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!app || !input?.pageKeysForTravel || !input.wheelDeltaLines) return false;
|
||||
if (!app || !input?.pageKeysForGesture || !input.wheelDeltaLines) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it (a shell running htop), as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
const target = { terminal: this.terminal, sessionId: this.sessionId };
|
||||
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
|
||||
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
|
||||
// forwarding session is never paged.
|
||||
// A wheel for any tracked CLI (a session the forwarding gate accepts) 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
|
||||
@@ -1181,23 +1213,42 @@
|
||||
if (!app._terminalViewportAtBottom?.(this.terminal)) return false;
|
||||
const lines = input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
if (!lines) return false;
|
||||
const step = input.pageKeysForTravel(this._pageKeyPending, lines, this.terminal.rows);
|
||||
this._pageKeyPending = step.pending;
|
||||
// An open dialog (a pending permission prompt or question) in THIS tile's
|
||||
// session, which is usually not the active one: a page key would move its
|
||||
// selector. Consumed, so xterm does not scroll the rows under it either.
|
||||
if (app.tabAlerts?.get(this.sessionId) === 'action') return true;
|
||||
// A Codex selection menu is not an alert, but PageUp/PageDown move its selection (see
|
||||
// CodemanTerminalInput.isCodexSelectionMenu), so it consumes the wheel the same way.
|
||||
if (app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })) return true;
|
||||
if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true;
|
||||
if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._scrollBytes = '';
|
||||
const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now());
|
||||
this._pageKeyGesture = step.state;
|
||||
if (step.keys) this._queueScrollBytes(step.keys);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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
|
||||
// one send per 40 ms after the leading native report, preserving upstream
|
||||
// report density and cancelling only unsent movement on reversal. 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
|
||||
// session, over this pane's socket while it is open.
|
||||
_queueScrollBytes(data) {
|
||||
if (!data || this._destroyed) return;
|
||||
if (this._scrollBytes.length > 512) return;
|
||||
this._scrollBytes += data;
|
||||
this._scrollBytes = global.CodemanTerminalInput.coalesceScrollBytes(this._scrollBytes, data);
|
||||
// Tiles have a fixed session: the leading report is once per connect(),
|
||||
// unlike the primary pane, which resets it on every tab selection.
|
||||
if (data.startsWith('\x1b[<') && !this._wheelNativeStarted) {
|
||||
this._wheelNativeStarted = true;
|
||||
if (this._scrollFlushTimer) clearTimeout(this._scrollFlushTimer);
|
||||
this._scrollFlushTimer = null;
|
||||
const bytes = this._scrollBytes;
|
||||
this._scrollBytes = '';
|
||||
global.app?._sendInputEphemeral?.(this.sessionId, bytes);
|
||||
return;
|
||||
}
|
||||
if (this._scrollFlushTimer) return;
|
||||
this._scrollFlushTimer = setTimeout(() => {
|
||||
this._scrollFlushTimer = null;
|
||||
@@ -1536,7 +1587,7 @@
|
||||
clearTimeout(this._scrollFlushTimer);
|
||||
this._scrollFlushTimer = null;
|
||||
this._scrollBytes = '';
|
||||
this._pageKeyPending = 0;
|
||||
this._pageKeyGesture = null;
|
||||
this._detachSocket();
|
||||
if (this._onFocusIn) {
|
||||
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
|
||||
|
||||
+854
-204
File diff suppressed because it is too large.
Load diff
+438
-20
@@ -56,6 +56,26 @@ const TILE_GRID_REFIT_MS = 150;
|
||||
// the grid section's padding (styles.css .tile-grid), for the drag math.
|
||||
const TILE_DIVIDER_PX = 6;
|
||||
const TILE_GRID_PADDING_PX = 4;
|
||||
// What a tile's header drag (and a popped-out window's title drag) carries:
|
||||
// the session id under a type of its own, never text, so no text field or
|
||||
// terminal takes it as typing. Another Codeman window reads it on drop.
|
||||
const TILE_DRAG_TYPE = 'application/x-codeman-tile';
|
||||
// Detach Tiles: a tile dropped outside every window waits this long before it
|
||||
// opens as a window of its own, so another window that took it (a
|
||||
// 'tile-adopted' message on the window channel) can still say so first.
|
||||
const TILE_TEAR_OFF_SETTLE_MS = 120;
|
||||
// How long after its drag ended a tile can still be claimed by another
|
||||
// window's drop (the claim and the dragend come from two windows).
|
||||
const TILE_ADOPT_WINDOW_MS = 5000;
|
||||
// A session docked here from another window: its old window's 'detached'
|
||||
// announcements this soon after are stale (it is closing), not a new pop-out.
|
||||
const TILE_DOCK_GRACE_MS = 2000;
|
||||
// The smallest window a detached tile opens as, and roughly how much browser
|
||||
// chrome sits above a pop-out's content (its title and address bars), so the
|
||||
// window's own header lands near where the tile was let go.
|
||||
const TILE_WINDOW_MIN_W = 480;
|
||||
const TILE_WINDOW_MIN_H = 320;
|
||||
const TILE_WINDOW_CHROME_PX = 60;
|
||||
|
||||
/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
|
||||
function tileGridTracks(fr) {
|
||||
@@ -324,12 +344,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionId = tileAt(e.target);
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (!sessionId || files.length === 0) return;
|
||||
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
|
||||
if (images.length === 0) {
|
||||
this.showToast?.('Only image files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(images, { sessionId });
|
||||
// The one classifier every pick and drop filter reads (image-input.js):
|
||||
// MIME first, then the name's extension, so documents and videos count too.
|
||||
// It toasts what it refuses.
|
||||
const media = this._admitPromptAttachments(files);
|
||||
if (media.length > 0) this._uploadAndInsertImages?.(media, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1552,8 +1571,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `accepts(id)` is the target's own rule (a tile takes any session but its
|
||||
* own; an empty cell takes any). A session it does not accept
|
||||
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
|
||||
* drop follows). Any other drag (a file) is left to the grid section's own
|
||||
* guard (_installTileFileDrop).
|
||||
* drop follows). With Detach Tiles on, a tile dragged from ANOTHER Codeman
|
||||
* window (no drag of this page's own, the tile type in the drag) is taken
|
||||
* too and docks here (_dockForeignTile). Any other drag (a file) is left to
|
||||
* the grid section's own guard (_installTileFileDrop).
|
||||
*/
|
||||
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
|
||||
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
|
||||
@@ -1561,7 +1582,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
'dragover',
|
||||
(e) => {
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
if (!id) {
|
||||
// A tile from another Codeman window (Detach Tiles). Its id can
|
||||
// only be read on drop, so it is held here whatever it is.
|
||||
if (!this._isForeignTileDrag(e)) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
el.classList.add('tile--drop-target');
|
||||
return;
|
||||
}
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
const ok = accepts(id);
|
||||
@@ -1578,7 +1608,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
(e) => {
|
||||
el.classList.remove('tile--drop-target');
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
if (!id) {
|
||||
if (!this._isForeignTileDrag(e)) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
this._dockForeignTile(e.dataTransfer?.getData?.(TILE_DRAG_TYPE), onDrop);
|
||||
return;
|
||||
}
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (accepts(id)) onDrop(id);
|
||||
@@ -2080,7 +2116,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* input starts no drag. dragstart's target is the header whatever was
|
||||
* pressed, so where the press landed is noted in the capture phase, ahead of
|
||||
* the buttons' own stopPropagation. `draggable` is off while moving is
|
||||
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
|
||||
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress),
|
||||
* unless Detach Tiles is on: then a drop outside the window pops the tile
|
||||
* out, and a drop on another Codeman window's tiles moves it there (see the
|
||||
* Detach Tiles section); a rename still turns it off.
|
||||
*/
|
||||
_installTileMoveDrag(sessionId, header, actions) {
|
||||
let pressedOnControl = false;
|
||||
@@ -2094,7 +2133,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
header.addEventListener('dragstart', (e) => {
|
||||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||||
if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
|
||||
// Detach Tiles lets a tile that cannot move (alone, or zoomed) still be
|
||||
// dragged, out of the window; no drop target here takes it then.
|
||||
const detachable = this._tileDetachAllowed();
|
||||
if (!entry || pressedOnControl || entry.renaming || !(this._tilesMovable() || detachable)) {
|
||||
e.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
@@ -2102,11 +2144,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
entry.el.classList.add('tile--dragging');
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox starts no drag without data.
|
||||
e.dataTransfer.setData('application/x-codeman-tile', sessionId);
|
||||
// Firefox starts no drag without data. Another Codeman window reads
|
||||
// the id from it (Detach Tiles).
|
||||
e.dataTransfer.setData(TILE_DRAG_TYPE, sessionId);
|
||||
}
|
||||
if (detachable) this._beginTileTearOff(sessionId, entry, e);
|
||||
});
|
||||
header.addEventListener('dragend', (e) => {
|
||||
this._settleTileTearOff(sessionId, e);
|
||||
this._endTileMoveDrag();
|
||||
});
|
||||
header.addEventListener('dragend', () => this._endTileMoveDrag());
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2117,6 +2164,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
_endTileMoveDrag() {
|
||||
this._draggedTileId = null;
|
||||
this._stopTileTearOffTracking();
|
||||
const grid = this._tileGrid;
|
||||
for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
|
||||
for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
|
||||
@@ -2124,19 +2172,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* The header as a handle: `draggable` while the tile can move (not while a
|
||||
* tile is zoomed, alone, or being renamed), and its tooltip, the state and
|
||||
* how long ("working 3m") plus, while it can move, that it drags. Diffs on
|
||||
* the last English text, never the DOM (translated in zh-CN; see
|
||||
* tile is zoomed, alone, or being renamed) or, with Detach Tiles on, be
|
||||
* dragged out of the window (then also alone or zoomed), and its tooltip,
|
||||
* the state and how long ("working 3m") plus what a drag does. Diffs on the
|
||||
* last English text, never the DOM (translated in zh-CN; see
|
||||
* _renderTileOverlay).
|
||||
*/
|
||||
_paintTileHandle(entry) {
|
||||
const movable = this._tilesMovable();
|
||||
const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
|
||||
const detachable = this._tileDetachAllowed();
|
||||
let hint = '';
|
||||
if (movable && detachable) hint = 'Drag to move the tile, or out of the window to open it on its own';
|
||||
else if (movable) hint = 'Drag to move the tile';
|
||||
else if (detachable) hint = 'Drag out of the window to open the tile on its own';
|
||||
const title = [entry.stateLabel, hint].filter(Boolean).join('\n');
|
||||
if (entry.headerLabel !== title) {
|
||||
entry.headerLabel = title;
|
||||
entry.header.title = title;
|
||||
}
|
||||
const draggable = movable && !entry.renaming ? 'true' : 'false';
|
||||
const draggable = (movable || detachable) && !entry.renaming ? 'true' : 'false';
|
||||
if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
|
||||
},
|
||||
|
||||
@@ -2859,6 +2913,370 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ── Detach Tiles (per-device `tileDetachEnabled`, default OFF) ───────────
|
||||
//
|
||||
// A tile leaves the grid for a window of its own, and comes back into any
|
||||
// Codeman window's grid, by dragging:
|
||||
//
|
||||
// - its header dropped OUTSIDE every window opens it as a pop-out window
|
||||
// (detachSession, the tab's own "Open in a new window"), at the drop point
|
||||
// and the tile's size;
|
||||
// - its header dropped on ANOTHER Codeman window's tile or empty cell moves
|
||||
// it there: that window docks it and says so on the window channel
|
||||
// ('tile-adopted'), and this one lets the tile go;
|
||||
// - a pop-out window's title dropped on a window's tile or empty cell docks
|
||||
// the session there, and the pop-out closes.
|
||||
//
|
||||
// Nothing new on the server: a pop-out is another client of the same session
|
||||
// (see detachSession), and a tile is a TerminalTile in whichever window has it.
|
||||
// A session stays in ONE place in this browser, as with every pop-out.
|
||||
|
||||
/** Whether Detach Tiles is on for this device. */
|
||||
tileDetachEnabled() {
|
||||
return this.loadAppSettingsFromStorage?.()?.tileDetachEnabled === true;
|
||||
},
|
||||
|
||||
/** Detach Tiles applies here: on, and a dashboard (a pop-out has no grid to drag from). */
|
||||
_tileDetachAllowed() {
|
||||
return !this.isSoloWindow && this.tileDetachEnabled();
|
||||
},
|
||||
|
||||
/**
|
||||
* A drag over this page that is a tile from another Codeman window: none of
|
||||
* this page's own drags is in flight, Detach Tiles is on and the drag
|
||||
* carries the tile type (its id is only readable on drop).
|
||||
*/
|
||||
_isForeignTileDrag(e) {
|
||||
if (this.draggedTabId || this._draggedTileId || !this._tileGrid?.open || !this._tileDetachAllowed()) return false;
|
||||
const types = e?.dataTransfer?.types;
|
||||
return !!types && Array.from(types).includes(TILE_DRAG_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* A tile from another window dropped on one of this grid's targets: it docks
|
||||
* through the target's own drop (`place`: a tile it replaces or trades
|
||||
* places with, an empty cell it fills), and takes focus there (a human
|
||||
* selection: the user just put it there). A session that was popped out comes
|
||||
* back first (its window is asked to close). Once it is a tile here, every
|
||||
* other window hears 'tile-adopted', and the one it was dragged from lets
|
||||
* its tile go (_onTileAdoptedElsewhere). An id this page does not know (a
|
||||
* session closed meanwhile, another user's) docks nowhere.
|
||||
*
|
||||
* @returns {boolean} whether the session is a tile here afterwards
|
||||
*/
|
||||
_dockForeignTile(sessionId, place) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || typeof sessionId !== 'string' || !this.sessions.has(sessionId)) return false;
|
||||
const poppedOut = !!this.detachedSessions?.has(sessionId);
|
||||
// A pop-out window owns its session's place: take it back before mounting
|
||||
// (a detached session never mounts).
|
||||
if (poppedOut) this._redock?.(sessionId);
|
||||
// Already a tile here too (two windows showed it): it moves to where it
|
||||
// was dropped, as a drag inside this window would move it.
|
||||
place(sessionId);
|
||||
if (!grid.has(sessionId)) return false;
|
||||
(this._tileDockedAt ||= new Map()).set(sessionId, Date.now());
|
||||
// Asked unconditionally: a pop-out this window never heard of (opened from
|
||||
// another dashboard) closes too. Dashboards ignore it; only that id's
|
||||
// pop-out reacts.
|
||||
this._postWindowMessage?.({ type: 'close-request', id: sessionId });
|
||||
this._postWindowMessage?.({ type: 'tile-adopted', id: sessionId, by: this._wsTabNonce });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* True for a session this window docked from another one a moment ago: a
|
||||
* 'detached' announcement from its old pop-out is that window on its way
|
||||
* out, never a new pop-out (it would take the tile straight back off).
|
||||
*/
|
||||
_tileDockedRecently(sessionId) {
|
||||
const at = this._tileDockedAt?.get(sessionId);
|
||||
return typeof at === 'number' && Date.now() - at < TILE_DOCK_GRACE_MS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Another window docked `sessionId` ('tile-adopted'). Only the window it
|
||||
* was dragged FROM acts: its drag is still settling or ended a moment ago.
|
||||
* The tile goes without taking the keyboard anywhere (the user is in the
|
||||
* other window now). The last tile leaving closes the grid onto the welcome
|
||||
* screen, never onto that session: two windows sizing one PTY garble it.
|
||||
*/
|
||||
_onTileAdoptedElsewhere(sessionId) {
|
||||
const recent = this._lastTileDrag;
|
||||
// A header drag counts even without a tear-off (Detach Tiles off here): the
|
||||
// other window can take the tile before this drag ends.
|
||||
const dragging = this._tileTearOff?.id === sessionId || this._draggedTileId === sessionId;
|
||||
const pending = this._tileTearOffPending?.id === sessionId;
|
||||
const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS;
|
||||
if (!dragging && !pending && !justEnded) return false;
|
||||
this._cancelTileTearOff();
|
||||
this._lastTileDrag = null;
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.has(sessionId)) return false;
|
||||
// One stored grid per browser, and the window that docked the tile has
|
||||
// just written it: this window losing the tile is no change of the
|
||||
// user's to remember over that one (nor to forget it).
|
||||
this._tilePersistHold = true;
|
||||
try {
|
||||
if (grid.ids.length > 1) return this.removeTile(sessionId, { focus: false });
|
||||
this.closeTileGrid({ keepStored: true, reselect: false });
|
||||
} finally {
|
||||
this._tilePersistHold = false;
|
||||
}
|
||||
this.activeSessionId = null;
|
||||
try {
|
||||
localStorage.removeItem('codeman-active-session');
|
||||
} catch {
|
||||
/* Nothing stored. */
|
||||
}
|
||||
this.terminal?.clear();
|
||||
this.showWelcome?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* A header drag starts with Detach Tiles on: note the tile's size and where
|
||||
* in it the press landed (the pop-out opens that size, under the pointer),
|
||||
* and watch whether the pointer leaves the page. Two signals, since
|
||||
* browsers differ in what dragend reports for a drop outside the window:
|
||||
* its coordinates, and the page's own dragleave at the viewport's edge
|
||||
* with nothing entered (cleared again by any dragover inside).
|
||||
*/
|
||||
_beginTileTearOff(sessionId, entry, e) {
|
||||
this._stopTileTearOffTracking();
|
||||
const rect = entry.el.getBoundingClientRect();
|
||||
const within = (v, max) => (Number.isFinite(v) ? Math.max(0, Math.min(v, max)) : 0);
|
||||
const drag = {
|
||||
id: sessionId,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
offsetX: within(e?.clientX - rect.left, rect.width),
|
||||
offsetY: within(e?.clientY - rect.top, rect.height),
|
||||
outside: false,
|
||||
stop: null,
|
||||
};
|
||||
const onOver = () => {
|
||||
drag.outside = false;
|
||||
};
|
||||
const onLeave = (ev) => {
|
||||
if (ev.relatedTarget) return;
|
||||
const x = ev.clientX;
|
||||
const y = ev.clientY;
|
||||
if (x <= 0 || y <= 0 || x >= window.innerWidth - 1 || y >= window.innerHeight - 1) drag.outside = true;
|
||||
};
|
||||
document.addEventListener('dragover', onOver, true);
|
||||
document.addEventListener('dragleave', onLeave, true);
|
||||
drag.stop = () => {
|
||||
document.removeEventListener('dragover', onOver, true);
|
||||
document.removeEventListener('dragleave', onLeave, true);
|
||||
};
|
||||
this._tileTearOff = drag;
|
||||
},
|
||||
|
||||
/** The header drag is over (or the grid or tile went mid-drag): stop watching the pointer. */
|
||||
_stopTileTearOffTracking() {
|
||||
const drag = this._tileTearOff;
|
||||
if (!drag) return;
|
||||
this._tileTearOff = null;
|
||||
drag.stop?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* dragend of a tile's header with Detach Tiles on. Taken by a drop target
|
||||
* (here or in another window: `dropEffect` is not 'none'), it stays where
|
||||
* that put it. Let go OUTSIDE the window (the dragend point, or the page's
|
||||
* own record of the pointer leaving), it opens as a window of its own after
|
||||
* TILE_TEAR_OFF_SETTLE_MS, unless another window claims it first. Let go
|
||||
* inside, on nothing, nothing happens, as before.
|
||||
*/
|
||||
_settleTileTearOff(sessionId, e) {
|
||||
const drag = this._tileTearOff?.id === sessionId ? this._tileTearOff : null;
|
||||
this._lastTileDrag = { id: sessionId, at: Date.now() };
|
||||
if (!drag) return false;
|
||||
this._stopTileTearOffTracking();
|
||||
const effect = e?.dataTransfer?.dropEffect;
|
||||
if (effect && effect !== 'none') return false;
|
||||
const cx = e?.clientX;
|
||||
const cy = e?.clientY;
|
||||
// (0, 0) is what a browser reports when it does not know where the drop was.
|
||||
const known = Number.isFinite(cx) && Number.isFinite(cy) && !(cx === 0 && cy === 0);
|
||||
const outside = known ? cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight : drag.outside;
|
||||
if (!outside) return false;
|
||||
const sx = e?.screenX;
|
||||
const sy = e?.screenY;
|
||||
const placed = Number.isFinite(sx) && Number.isFinite(sy) && !(sx === 0 && sy === 0);
|
||||
const placement = {
|
||||
width: drag.width,
|
||||
height: drag.height,
|
||||
...(placed ? { screenX: sx, screenY: sy, offsetX: drag.offsetX, offsetY: drag.offsetY } : {}),
|
||||
};
|
||||
this._cancelTileTearOff();
|
||||
const pending = { id: sessionId, timer: null };
|
||||
pending.timer = setTimeout(() => {
|
||||
if (this._tileTearOffPending !== pending) return;
|
||||
this._tileTearOffPending = null;
|
||||
this.detachTile(sessionId, placement);
|
||||
}, TILE_TEAR_OFF_SETTLE_MS);
|
||||
this._tileTearOffPending = pending;
|
||||
return true;
|
||||
},
|
||||
|
||||
_cancelTileTearOff() {
|
||||
const pending = this._tileTearOffPending;
|
||||
if (!pending) return;
|
||||
this._tileTearOffPending = null;
|
||||
clearTimeout(pending.timer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a tile as a window of its own (detachSession, which takes it out of
|
||||
* the grid), the tile's size (at least TILE_WINDOW_MIN_W x MIN_H). Placed
|
||||
* with its header under a screen point when given one (`screenX`/`screenY`
|
||||
* and where in the tile it was held, `offsetX`/`offsetY`: a drop outside the
|
||||
* window, a hand let go), otherwise over the tile itself (the ⋯ menu).
|
||||
*
|
||||
* @returns {boolean} whether the session is popped out afterwards
|
||||
*/
|
||||
detachTile(sessionId, { screenX, screenY, offsetX = 0, offsetY = 0, width, height } = {}) {
|
||||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||||
if (!entry || !this._tileDetachAllowed()) return false;
|
||||
const rect = entry.el.getBoundingClientRect();
|
||||
const availW = window.screen?.availWidth || Infinity;
|
||||
const availH = window.screen?.availHeight || Infinity;
|
||||
const size = (v, min, max) => Math.round(Math.min(Math.max(Number.isFinite(v) ? v : min, min), max));
|
||||
const placement = {
|
||||
width: size(width ?? rect.width, TILE_WINDOW_MIN_W, availW),
|
||||
height: size(height ?? rect.height, TILE_WINDOW_MIN_H, availH),
|
||||
};
|
||||
if (Number.isFinite(screenX) && Number.isFinite(screenY)) {
|
||||
placement.left = Math.round(screenX - offsetX);
|
||||
placement.top = Math.round(screenY - offsetY - TILE_WINDOW_CHROME_PX);
|
||||
} else if (Number.isFinite(window.screenX) && Number.isFinite(window.screenY)) {
|
||||
// Over the tile: the page's own chrome sits above its viewport.
|
||||
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
|
||||
placement.left = Math.round(window.screenX + rect.left);
|
||||
placement.top = Math.round(window.screenY + chrome + rect.top - TILE_WINDOW_CHROME_PX);
|
||||
}
|
||||
this.detachSession?.(sessionId, placement);
|
||||
return !!this.detachedSessions?.has(sessionId);
|
||||
},
|
||||
|
||||
// ── For the gesture overlay (packages/gesture-control, src/codeman/entry.ts)
|
||||
//
|
||||
// A hand is no native drag: the overlay asks where it is and what is under
|
||||
// it, and every move still goes through the paths a mouse drag takes
|
||||
// (dropSessionOnTile / dropSessionOnSlot, and so _reorderTiles), the
|
||||
// pop-out through detachTile.
|
||||
|
||||
/** The tiled session whose tile is at viewport point (x, y), or null. */
|
||||
tileAtPoint(x, y) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) return null;
|
||||
const hit = document.elementFromPoint?.(x, y);
|
||||
if (!hit) return null;
|
||||
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(hit)) return id;
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Whether a hand may pick up this tile: it can move, or (Detach Tiles) leave the window. */
|
||||
canGrabTile(sessionId) {
|
||||
return !!this._tileGrid?.has(sessionId) && (this._tilesMovable() || this._tileDetachAllowed());
|
||||
},
|
||||
|
||||
/**
|
||||
* What a session dragged to viewport point (x, y) would drop onto, by the
|
||||
* mouse drag's own rules: another tile (it takes that tile's place, or the
|
||||
* two trade places), an empty cell (it moves or joins there). A tiled
|
||||
* session only while tiles can move (not zoomed). Null for anything else,
|
||||
* a popped-out session included.
|
||||
*
|
||||
* @returns {{kind: 'tile', id: string, el: HTMLElement}|{kind: 'cell', cell: number, el: HTMLElement}|null}
|
||||
*/
|
||||
tileDropTargetAt(x, y, draggedId) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return null;
|
||||
if (grid.tiles.has(draggedId) && !this._tilesMovable()) return null;
|
||||
const hit = document.elementFromPoint?.(x, y);
|
||||
if (!hit) return null;
|
||||
for (const [id, entry] of grid.tiles) {
|
||||
if (entry.el.contains?.(hit)) return id === draggedId ? null : { kind: 'tile', id, el: entry.el };
|
||||
}
|
||||
for (const slot of grid.slots || []) {
|
||||
if (slot.contains?.(hit)) return { kind: 'cell', cell: Number(slot.dataset.cell), el: slot };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Drops a session on a target tileDropTargetAt returned (the hand let go).
|
||||
*
|
||||
* @returns {boolean} whether the session is a tile afterwards
|
||||
*/
|
||||
dropOnTileTarget(draggedId, target) {
|
||||
if (!target || !this._tileGrid?.open) return false;
|
||||
if (target.kind === 'tile') this.dropSessionOnTile(draggedId, target.id);
|
||||
else if (target.kind === 'cell') this.dropSessionOnSlot(draggedId, target.cell);
|
||||
return !!this._tileGrid?.has(draggedId);
|
||||
},
|
||||
|
||||
/** Whether viewport point (x, y) is over the open grid (a hand let go outside it pops the tile out). */
|
||||
isOverTileGrid(x, y) {
|
||||
if (!this._tileGrid?.open) return false;
|
||||
const rect = document.getElementById('tileGrid')?.getBoundingClientRect?.();
|
||||
return !!rect && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
|
||||
},
|
||||
|
||||
/**
|
||||
* A hand let a tile go outside the grid (Detach Tiles): it opens as a window
|
||||
* of its own with its header near that point. A script opening a window
|
||||
* with no click behind it is what popup blockers stop; detachSession then
|
||||
* says so and the tile stays.
|
||||
*/
|
||||
detachTileAtPoint(sessionId, x, y, { offsetX = 0, offsetY = 0 } = {}) {
|
||||
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
|
||||
const sx = (window.screenX || 0) + x;
|
||||
const sy = (window.screenY || 0) + chrome + y;
|
||||
return this.detachTile(sessionId, { screenX: sx, screenY: sy, offsetX, offsetY });
|
||||
},
|
||||
|
||||
/**
|
||||
* A pop-out window's title, as a handle (Detach Tiles): dragged onto a
|
||||
* Codeman window's tile or empty cell, the session docks there as a tile
|
||||
* and this window closes (_dockForeignTile, which asks it to). The same
|
||||
* drag type as a tile's header; on or off follows the setting, also when it
|
||||
* changes in another window.
|
||||
*/
|
||||
_installSoloTileHandle() {
|
||||
const title = document.getElementById('soloSessionTitle');
|
||||
if (!this.isSoloWindow || !title || title._codemanTileHandle) return;
|
||||
title._codemanTileHandle = true;
|
||||
const sync = () => {
|
||||
this._cachedAppSettings = null;
|
||||
const on = this.tileDetachEnabled();
|
||||
title.setAttribute('draggable', on ? 'true' : 'false');
|
||||
title.classList.toggle('solo-session-title--handle', on);
|
||||
const label = on ? "Drag onto a Codeman window's tiles to dock this session there" : '';
|
||||
if (title._codemanTileHandleLabel !== label) {
|
||||
title._codemanTileHandleLabel = label;
|
||||
title.title = label;
|
||||
}
|
||||
};
|
||||
sync();
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (!e.key || e.key === this.getSettingsStorageKey?.()) sync();
|
||||
});
|
||||
title.addEventListener('dragstart', (e) => {
|
||||
if (!this.tileDetachEnabled() || !this.soloSessionId) {
|
||||
e.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData(TILE_DRAG_TYPE, this.soloSessionId);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
|
||||
|
||||
@@ -325,6 +325,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
oncontextmenu="app.handleWebviewTabContextMenu(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
||||
@@ -347,6 +348,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Right-click (or the menu key) on a web tab in the vertical rail opens its
|
||||
* actions (Focus, group moves, settings), the flat rail included, where it is
|
||||
* the only way in. Everywhere else the browser keeps its own menu.
|
||||
*/
|
||||
handleWebviewTabContextMenu(event, id) {
|
||||
if (!this._tabLayoutEditable?.()) return false;
|
||||
return this.openTabWebviewMenu?.(event, id);
|
||||
},
|
||||
|
||||
/** Mark exactly one tab active across BOTH tab kinds. */
|
||||
_updateActiveWebviewTab() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -359,6 +370,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
this._syncTabTreeSelection?.(container);
|
||||
this._renderTabFocus?.();
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "1.48.0",
|
||||
"highlights": [
|
||||
"Talk to your agents from Discord (#741)",
|
||||
"Edit history panel (#693)",
|
||||
"Custom endpoints go further (#752, #755, #757)",
|
||||
"Clone into a custom folder, and make a folder from the picker (#744, #745)",
|
||||
"Relative links follow your `cd` (#740)",
|
||||
"File links keep the whole name (#735)",
|
||||
"A failed settings save says so (#736)",
|
||||
"Phone keys show a press (#737)"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @fileoverview Accept empty, untyped API POSTs even when a reverse proxy frames them as chunked.
|
||||
*
|
||||
* Cloudflare Tunnel (cloudflared, Go's HTTP client) forwards a bodyless POST as an HTTP/1.1
|
||||
* chunked request with no Content-Type and a zero-length body. Fastify only skips its
|
||||
* content-type check for a `Content-Length: 0` request, so this framing was refused 415
|
||||
* (`FST_ERR_CTP_INVALID_MEDIA_TYPE`) before the route ran, breaking actions such as opening a
|
||||
* saved web tab. This parser lets such a request reach its route with `request.body === undefined`,
|
||||
* exactly as a `Content-Length: 0` request does.
|
||||
*
|
||||
* Scope is POST under `/api/` only: Go frames an empty body of unknown length as chunked for
|
||||
* POST, PUT and PATCH and drops it for DELETE, and every PUT/PATCH the frontend sends carries a
|
||||
* JSON body. Anything else (another method, a non-API route, a Content-Type, a non-zero
|
||||
* Content-Length, or a single body byte) is still the 415 Fastify would have answered.
|
||||
*
|
||||
* ⚠️ It owns the ROOT `'*'` content-type slot. The JSON, `text/plain` (kept RAW, see the CSRF
|
||||
* note in CLAUDE.md) and multipart parsers match first; the web-tab proxy registers its own
|
||||
* `'*'` in an encapsulated scope and is unaffected. Fastify refuses a second root `'*'` parser at
|
||||
* boot (`FST_ERR_CTP_ALREADY_PRESENT`), so extend this function instead of adding one.
|
||||
*/
|
||||
import { errorCodes, type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
export function installEmptyApiPostParser(app: FastifyInstance): void {
|
||||
// Existing JSON, text and multipart parsers take precedence. The webview
|
||||
// proxy installs its own parser in an encapsulated scope.
|
||||
app.addContentTypeParser('*', async (req: FastifyRequest, payload: Readable) => {
|
||||
// Fastify normally skips an unsupported parser on its 404 path. Preserve
|
||||
// that behavior, including the unread stream used by referer fallbacks.
|
||||
if (req.is404) return undefined;
|
||||
|
||||
if (
|
||||
req.method !== 'POST' ||
|
||||
!req.routeOptions.url?.startsWith('/api/') ||
|
||||
req.headers['content-type'] !== undefined ||
|
||||
(req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0')
|
||||
) {
|
||||
throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE();
|
||||
}
|
||||
|
||||
// Wait for the terminating chunk to prove the body is empty. Reject on the
|
||||
// first byte instead of buffering an unsupported body. Leave the socket
|
||||
// alive so Fastify can send its 415 and close the connection on parser errors.
|
||||
try {
|
||||
for await (const chunk of payload.iterator({ destroyOnReturn: false })) {
|
||||
if (Buffer.byteLength(chunk) > 0) throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE();
|
||||
}
|
||||
} catch (error) {
|
||||
// Match Fastify's body reader: an interrupted request is a client error.
|
||||
const err = error as Error & { statusCode?: number };
|
||||
if (typeof err.statusCode !== 'number' || err.statusCode < 400) err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// No synthetic JSON or header rewriting: required-body validation still
|
||||
// sees the same undefined value as a direct Content-Length: 0 request.
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,65 @@ export interface ResponseViewerTranscriptBlock {
|
||||
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
||||
// would drag node-pty and the whole session layer into this pure module, so the
|
||||
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
|
||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'copilot']);
|
||||
|
||||
/**
|
||||
* A pane that is not Codex-shaped (`›` prompt, `•` bullets) describes its own dialect as data, so
|
||||
* a new CLI adds a row here instead of another branch in the Codex-oriented loop below.
|
||||
*/
|
||||
interface PaneDialect {
|
||||
/** Group 1 is the prompt text. A bare composer glyph with nothing after it must NOT match. */
|
||||
prompt: RegExp;
|
||||
/** A right-aligned timestamp the TUI appends to a submitted prompt. */
|
||||
promptTrailer?: RegExp;
|
||||
/** The glyph that opens an answer. */
|
||||
responseBullet: RegExp;
|
||||
/** Chrome that can sit between turns: banner notes, the cwd/credits row, the footer, spinners. */
|
||||
status: RegExp[];
|
||||
/** Rows that carry nothing (the empty composer), dropped and treated as a block boundary. */
|
||||
ignore: RegExp[];
|
||||
/**
|
||||
* The composer row, the one between two rules at the bottom of the screen. Only the LAST rule,
|
||||
* composer row, rule triple counts: an answer can hold a rule-delimited paragraph or a quoted
|
||||
* prompt of its own, and that is answer text.
|
||||
*/
|
||||
composer: RegExp;
|
||||
/** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */
|
||||
lineTrailer?: RegExp;
|
||||
/**
|
||||
* Text that is on screen only before the first prompt has scrolled the screen: the banner, a
|
||||
* trust dialog. Its presence is how "nothing has been asked yet" is told apart from "the prompt
|
||||
* row has scrolled off the top".
|
||||
*/
|
||||
startup: RegExp[];
|
||||
}
|
||||
|
||||
const PANE_DIALECTS: Record<string, PaneDialect> = {
|
||||
// GitHub Copilot CLI, measured on 1.0.95 inside tmux: ` ❯ <prompt> HH:MM`, then ` ● <answer>`
|
||||
// with 3-space continuation lines, a cwd + `Session: N AIC used` row, the composer between two
|
||||
// rules, and a `← open sidebar · … · / commands · ? help` footer. Tool-call rows are not
|
||||
// modelled: they open with the same bullet and read as part of the answer.
|
||||
copilot: {
|
||||
// Copilot draws a submitted prompt at column 0 or 1; the continuation rows of an answer are
|
||||
// indented three spaces, so a shell prompt inside a code block (` ❯ npm test`) is not a prompt.
|
||||
prompt: /^ ?❯\s+(\S.*)$/,
|
||||
promptTrailer: /\s{2,}\d{1,2}:\d{2}\s*$/,
|
||||
responseBullet: /^\s*●\s+/,
|
||||
status: [
|
||||
/^\s*(?:!|●)\s+(?:Tip:|MCP Servers?\b|Model\b)/,
|
||||
/^\s*!\s+/,
|
||||
/\bSession: .*(?:AIC used|credits unavailable)/,
|
||||
/^\s*←\s+open sidebar/,
|
||||
/\/ commands · \? help/,
|
||||
/^\s*[◉◎]\s+Working\b/,
|
||||
],
|
||||
ignore: [/^ ?❯\s*$/],
|
||||
composer: /^ ?❯/,
|
||||
// The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one).
|
||||
lineTrailer: /\s*[┃▐]\s*$/,
|
||||
startup: [/Copilot v\d+\.\d+\.\d+ uses AI/, /Confirm folder trust/, /Do you trust the files in this folder/],
|
||||
},
|
||||
};
|
||||
|
||||
function isPromptLine(line: string): boolean {
|
||||
return /^\s*›\s*/.test(line);
|
||||
@@ -265,6 +323,111 @@ export function isExternalCliTranscriptMode(mode: string | null | undefined): bo
|
||||
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this mode's pane is a repainting full-screen TUI whose output stream is absolute cursor
|
||||
* moves rather than lines. The segmenter needs the SCREEN for those (tmux capture-pane), because
|
||||
* stripping the escapes from the stream concatenates every row into one run of text.
|
||||
*/
|
||||
export function transcriptNeedsScreenCapture(mode: string | null | undefined): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(PANE_DIALECTS, String(mode || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment a pane that has its own dialect. Everything before the first prompt is the banner (or a
|
||||
* trust dialog) and is dropped, so a session that has not been asked anything answers "nothing
|
||||
* yet" rather than serving its logo as a reply.
|
||||
*
|
||||
* The screen is all there is to read, so a long answer scrolls the prompt row off the top. When no
|
||||
* prompt row is on screen AND no startup text is either, the session has already been asked
|
||||
* something: the rows above the composer are the tail of the last answer, not "nothing yet".
|
||||
*/
|
||||
function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] {
|
||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||
let kind: ResponseViewerTranscriptKind | null = null;
|
||||
let current: string[] = [];
|
||||
const rows = cleaned.split('\n').map((row) => (dialect.lineTrailer ? row.replace(dialect.lineTrailer, '') : row));
|
||||
// The composer sits between two rules and holds whatever is typed but not sent. That is not a
|
||||
// prompt row: otherwise typing under a tall answer would make the answer's tail read as banner.
|
||||
let composerIndex = -1;
|
||||
for (let i = rows.length - 2; i > 0; i -= 1) {
|
||||
if (
|
||||
dialect.composer.test(rows[i] ?? '') &&
|
||||
isDividerOnlyLine(rows[i - 1] ?? '') &&
|
||||
isDividerOnlyLine(rows[i + 1] ?? '')
|
||||
) {
|
||||
composerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isComposerRow = (i: number) => i === composerIndex;
|
||||
const isComposerRule = (i: number) => composerIndex !== -1 && (i === composerIndex - 1 || i === composerIndex + 1);
|
||||
const hasPrompt = rows.some((row, i) => dialect.prompt.test(row) && !isComposerRow(i));
|
||||
const startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row)));
|
||||
let sawPrompt = !hasPrompt && !startupVisible;
|
||||
// A rule drawn INSIDE an answer (a markdown `---`, a quoted passage between two rules) is part
|
||||
// of the answer, so it must not end the block, or `agent read` returns only the text after the
|
||||
// last rule. It is held until more of the same answer follows, so a rule that only separates
|
||||
// the answer from the footer never trails it. The composer's own two rules stay boundaries.
|
||||
let pendingRule = false;
|
||||
|
||||
const flush = () => {
|
||||
if (sawPrompt) pushBlock(blocks, kind, current);
|
||||
kind = null;
|
||||
current = [];
|
||||
pendingRule = false;
|
||||
};
|
||||
|
||||
for (const [index, line] of rows.entries()) {
|
||||
if (kind === 'response' && isDividerOnlyLine(line) && !isComposerRule(index)) {
|
||||
pendingRule = true;
|
||||
continue;
|
||||
}
|
||||
if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
const prompt = dialect.prompt.exec(line);
|
||||
if (prompt) {
|
||||
flush();
|
||||
sawPrompt = true;
|
||||
kind = 'prompt';
|
||||
current = [dialect.promptTrailer ? prompt[1].replace(dialect.promptTrailer, '').trim() : prompt[1].trim()];
|
||||
continue;
|
||||
}
|
||||
if (!sawPrompt) continue;
|
||||
if (dialect.status.some((re) => re.test(line))) {
|
||||
if (kind !== 'status') flush();
|
||||
kind = 'status';
|
||||
current.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
// A wrapped warning or tip: indented under its status row.
|
||||
if (kind === 'status' && /^\s{3,}\S/.test(line)) {
|
||||
current.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
if (!line.trim()) {
|
||||
if (kind) current.push('');
|
||||
continue;
|
||||
}
|
||||
const bullet = dialect.responseBullet.exec(line);
|
||||
if (bullet) {
|
||||
flush();
|
||||
kind = 'response';
|
||||
current = [line.slice(bullet[0].length)];
|
||||
continue;
|
||||
}
|
||||
if (!kind) kind = 'response';
|
||||
if (pendingRule) {
|
||||
current.push('', '---', '');
|
||||
pendingRule = false;
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
flush();
|
||||
return blocks.filter((block) => block.text.trim().length > 0);
|
||||
}
|
||||
|
||||
export function parseExternalCliTranscript(
|
||||
buffer: string,
|
||||
mode: string | null | undefined
|
||||
@@ -275,6 +438,9 @@ export function parseExternalCliTranscript(
|
||||
const cleaned = cleanTerminalTranscript(buffer);
|
||||
if (!cleaned) return [];
|
||||
|
||||
const dialect = PANE_DIALECTS[resolvedMode];
|
||||
if (dialect) return parseDialectTranscript(cleaned, dialect);
|
||||
|
||||
const lines = trimLeadingStartup(cleaned.split('\n'));
|
||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||
let currentKind: ResponseViewerTranscriptKind | null = null;
|
||||
|
||||
@@ -11,12 +11,22 @@ import fs from 'node:fs/promises';
|
||||
import type { z } from 'zod';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse, type AuthUser, type SessionState } from '../types.js';
|
||||
import type { WaitCapacityError } from './session-wait-registry.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
|
||||
/**
|
||||
* Longest path PARAMETER the router accepts (Fastify's default is 100). A file
|
||||
* name goes up to 255 characters and arrives percent-encoded, so the folder
|
||||
* grid's `…/files/:name/raw` answered 404 "Route not found" for a name the
|
||||
* listing happily showed. Raw length, not decoded: 255 four-byte characters are
|
||||
* 3060 characters of `%XX`.
|
||||
*/
|
||||
export const MAX_ROUTE_PARAM_LENGTH = 4096;
|
||||
import type { AuthSessionRecord } from './ports/auth-port.js';
|
||||
import type { StaleExpirationMap } from '../utils/index.js';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
@@ -315,11 +325,33 @@ function sessionNotFoundError(sessionId: string): Error & { statusCode: number;
|
||||
* Look up a session by ID or throw a structured error.
|
||||
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
|
||||
*
|
||||
* When `req` is passed in multi-user mode, a session the caller does not own is
|
||||
* reported as NOT_FOUND (never 403), so existence of other users' sessions is not
|
||||
* leaked. Single-user / admin callers are unaffected.
|
||||
* In multi-user mode a session the caller does not own is reported as NOT_FOUND
|
||||
* (never 403), so existence of other users' sessions is not leaked. Single-user /
|
||||
* admin callers are unaffected (`canAccessOwned` short-circuits when multi-user is off).
|
||||
*
|
||||
* `req` is REQUIRED. When it was optional, forgetting it compiled cleanly and
|
||||
* silently performed an UNSCOPED lookup. A caller with no request in flight must
|
||||
* say so at the call site via {@link findSessionUnscopedOrFail}.
|
||||
*/
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req: FastifyRequest): Session {
|
||||
return lookupSessionOrFail(ctx, sessionId, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a session WITHOUT ownership scoping. Deliberately verbose and greppable:
|
||||
* every call is a decision to skip the multi-user ownership check and should carry
|
||||
* a comment saying why (e.g. a startup path with no request in flight). Inside a
|
||||
* request handler use `findSessionOrFail(ctx, id, req)` instead.
|
||||
*
|
||||
* It has no production callers today: every lookup in `src/` is request-scoped. It
|
||||
* exists so the next genuinely request-less caller has somewhere to go other than
|
||||
* making `req` optional again.
|
||||
*/
|
||||
export function findSessionUnscopedOrFail(ctx: SessionPort, sessionId: string): Session {
|
||||
return lookupSessionOrFail(ctx, sessionId, undefined);
|
||||
}
|
||||
|
||||
function lookupSessionOrFail(ctx: SessionPort, sessionId: string, req: FastifyRequest | undefined): Session {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
if (!session) throw sessionNotFoundError(sessionId);
|
||||
if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId);
|
||||
@@ -334,15 +366,18 @@ export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: Fas
|
||||
* return, so this returns the persisted record instead. Same ownership
|
||||
* enforcement, same 404-not-403 leak protection — this is that function's
|
||||
* missing other half, not a separate check reimplemented inline.
|
||||
*
|
||||
* `req` is REQUIRED for the same reason as on `findSessionOrFail`: an optional
|
||||
* one would let a caller that forgot it retire another user's row in multi-user mode.
|
||||
*/
|
||||
export function findPersistedSessionOrFail(
|
||||
store: { getSession(id: string): SessionState | null },
|
||||
sessionId: string,
|
||||
req?: FastifyRequest
|
||||
req: FastifyRequest
|
||||
): SessionState {
|
||||
const persisted = store.getSession(sessionId);
|
||||
if (!persisted) throw sessionNotFoundError(sessionId);
|
||||
if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
|
||||
if (!canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
@@ -618,3 +653,50 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Long-poll helpers (shared by the wait routes and the inbox routes) ==========
|
||||
|
||||
/**
|
||||
* Map a waiter-cap rejection to the code that tells the caller the truth.
|
||||
*
|
||||
* The two caps mean different things and warrant different recovery: `session` is
|
||||
* genuinely about THIS session, while `owner` and `total` are process-wide budgets
|
||||
* that say nothing about it. Reporting a global cap as `SESSION_BUSY` (409,
|
||||
* documented as "Session is busy") sent an agent off to a different session to hit
|
||||
* the identical error. `RATE_LIMITED` is the code whose whole meaning is "come back
|
||||
* later", and clients and proxies already treat 429 that way.
|
||||
*/
|
||||
export function waitCapacityResponse(err: WaitCapacityError): ApiResponse<never> {
|
||||
const code = err.scope === 'session' ? ApiErrorCode.SESSION_BUSY : ApiErrorCode.RATE_LIMITED;
|
||||
// The registry's message already names the scope and the limit; passing it through
|
||||
// verbatim keeps the wording in one place.
|
||||
return createErrorResponse(code, err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* An `AbortController` that fires when the CLIENT goes away, and only then.
|
||||
*
|
||||
* Freeing an abandoned waiter matters because the documented pattern is a loop of
|
||||
* short waits: `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter
|
||||
* every iteration until the cap is hit and an innocent session reports busy. Same for
|
||||
* any proxy that cuts the connection.
|
||||
*
|
||||
* ⚠️ **It must listen on the RESPONSE, not the request.** `req.raw` emits `'close'`
|
||||
* as soon as the request body has finished streaming, which on a POST is BEFORE the
|
||||
* handler ever blocks — measured at +1ms with `aborted: false`, indistinguishable
|
||||
* from a real hang-up at +0ms. Wiring the abort there cancels every send-and-wait
|
||||
* instantly and silently kills the feature (it survives on GET only because a GET has
|
||||
* no body to finish). `reply.raw` emits `'close'` both when the response completes
|
||||
* and when the socket dies, and `writableFinished` is what tells those apart: true
|
||||
* only if the response actually went out. The guard is load-bearing, not defensive.
|
||||
*
|
||||
* `app.inject()` never emits `'close'` at all, so this is only observable over real
|
||||
* HTTP — which is why the regression test for it binds a port.
|
||||
*/
|
||||
export function abortOnClientHangUp(reply: FastifyReply): AbortController {
|
||||
const controller = new AbortController();
|
||||
reply.raw.on('close', () => {
|
||||
if (!reply.raw.writableFinished) controller.abort();
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
@@ -36,11 +36,14 @@ import {
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import type { EventPort } from '../ports/event-port.js';
|
||||
import type { TabLayoutPort } from '../ports/tab-layout-port.js';
|
||||
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
@@ -72,7 +75,10 @@ function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof create
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
|
||||
export function registerAdminRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & AuthPort & EventPort & TabLayoutPort
|
||||
): void {
|
||||
// Gate: admin routes exist only in multi-user mode, and only for admins.
|
||||
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
|
||||
if (!isMultiUserMode()) {
|
||||
@@ -183,7 +189,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
// Web-tab proxy capabilities are a second credential the cookie purge does not
|
||||
// touch; a forced logout that left them alive would not be a logout.
|
||||
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
audit(req, 'user.logout', username, { revoked, revokedWebviews });
|
||||
const revokedHtmlViews = htmlViewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
audit(req, 'user.logout', username, { revoked, revokedWebviews, revokedHtmlViews });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
@@ -206,6 +213,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
webviewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
@@ -296,6 +304,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
);
|
||||
}
|
||||
await fsp.rm(realTarget, { recursive: true, force: true });
|
||||
// The user's default session group for that case goes with it.
|
||||
await ctx.tabLayouts.forgetCaseDefaultGroup(ownerLayoutKey(username), params.caseName).catch(() => undefined);
|
||||
audit(req, 'user.case-delete', username, { caseName: params.caseName });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { username, caseName: params.caseName } };
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints.
|
||||
* Ownership is checked before any disk or pane read. Native evidence comes only
|
||||
* from strict conversation attribution; all other modes get labeled snapshots.
|
||||
*
|
||||
* Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the
|
||||
* setting, until the synced `agentCoordinationEnabled` is on (default OFF), the
|
||||
* same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first,
|
||||
* before session lookup, so a disabled install reveals nothing about its sessions.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Session } from '../../session.js';
|
||||
import type { SessionPort, InfraPort } from '../ports/index.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js';
|
||||
import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js';
|
||||
import {
|
||||
GuardedStageStore,
|
||||
GuardedInputConflict,
|
||||
guardedComposerChrome,
|
||||
MAY_BE_TYPED,
|
||||
type GuardedInputTarget,
|
||||
} from '../../agent-staging.js';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js';
|
||||
|
||||
/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */
|
||||
export async function readAgentCoordinationEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.agentCoordinationEnabled === true;
|
||||
}
|
||||
|
||||
/** The refusal every route sends while the setting is off. Exported for the tests. */
|
||||
export const AGENT_COORDINATION_DISABLED_MESSAGE =
|
||||
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
|
||||
|
||||
async function gate(): Promise<ApiResponse<never> | null> {
|
||||
if (await readAgentCoordinationEnabled()) return null;
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE);
|
||||
}
|
||||
|
||||
/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */
|
||||
export function errorCode(error: unknown): string {
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code;
|
||||
return error instanceof Error ? error.constructor.name : 'unknown';
|
||||
}
|
||||
|
||||
export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
|
||||
const reader = new InspectionReader();
|
||||
const stages = new GuardedStageStore();
|
||||
app.addHook('onClose', async () => {
|
||||
reader.clear();
|
||||
stages.clear();
|
||||
});
|
||||
const generation = (session: Session) => session.inspectionGeneration;
|
||||
const target = (session: Session): GuardedInputTarget => ({
|
||||
id: session.id,
|
||||
chrome: () => guardedComposerChrome(getCli(session.mode)),
|
||||
snapshot: () => {
|
||||
if (ctx.sessions.get(session.id) !== session) return null;
|
||||
const snapshot = session.guardedInputSnapshot?.();
|
||||
return snapshot ?? null;
|
||||
},
|
||||
write: (text) => session.stageGuardedInput(text),
|
||||
submit: (expected) => session.submitGuardedInput(expected),
|
||||
});
|
||||
app.get('/api/sessions/:id/inspect', async (req, reply) => {
|
||||
const refused = await gate();
|
||||
if (refused) return reply.code(403).send(refused);
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query);
|
||||
const initialGeneration = generation(session);
|
||||
const identity = { sessionId: session.id, mode: session.mode };
|
||||
const activity = { status: session.status, lastSubmitAt: session.lastSubmitAt || null, observedAt: Date.now() };
|
||||
try {
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout' && !session.remote && !session.docker) {
|
||||
const conversation = await findExactCodexConversation(
|
||||
{
|
||||
codexHome: resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir),
|
||||
sessionId: session.id,
|
||||
workingDir: session.workingDir,
|
||||
resumeSessionId: session.codexConfig?.resumeSessionId,
|
||||
},
|
||||
{ maxEntries: 8192, maxBytes: 1048576 }
|
||||
);
|
||||
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
|
||||
throw new InspectionConflict('Session changed during inspection');
|
||||
if (conversation) {
|
||||
const result = await reader.read(
|
||||
conversation.path,
|
||||
{
|
||||
owner: getAuthUser(req).username,
|
||||
sessionId: id,
|
||||
generation: initialGeneration,
|
||||
conversationId: conversation.id,
|
||||
},
|
||||
maxBytes,
|
||||
since
|
||||
);
|
||||
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
|
||||
throw new InspectionConflict('Session changed during inspection');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
source: 'codex-jsonl',
|
||||
identity: { ...identity, conversationId: conversation.id },
|
||||
activity,
|
||||
incremental: true,
|
||||
maxBytes,
|
||||
...result,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
if (since)
|
||||
throw new InspectionConflict('Attributed transcript unavailable; this cursor cannot resume a pane snapshot');
|
||||
const frame = session.muxName ? ctx.mux.capturePaneText?.(session.muxName) : null;
|
||||
const evidence = frame ?? session.terminalBuffer;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
source: 'pane-snapshot',
|
||||
identity,
|
||||
activity,
|
||||
incremental: false,
|
||||
cursor: null,
|
||||
maxBytes,
|
||||
evidence: boundedUtf8Tail(evidence, maxBytes),
|
||||
truncated: evidence.length > maxBytes || Buffer.byteLength(evidence) > maxBytes,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof InspectionConflict)
|
||||
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message));
|
||||
// Anything else (an unreadable rollout, an IO error) is not a retryable cursor
|
||||
// conflict. Log the session and the error code only: the message can carry the
|
||||
// native transcript path, which must never reach the log or the response.
|
||||
console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`);
|
||||
return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable'));
|
||||
}
|
||||
});
|
||||
app.post('/api/sessions/:id/stage', async (req, reply) => {
|
||||
const refused = await gate();
|
||||
if (refused) return reply.code(403).send(refused);
|
||||
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
|
||||
const { message } = parseBody(AgentStageSchema, req.body);
|
||||
try {
|
||||
const receipt = await stages.stage(target(session), getAuthUser(req).username, message);
|
||||
return { success: true, data: receipt };
|
||||
} catch (error) {
|
||||
if (!(error instanceof GuardedInputConflict))
|
||||
console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`);
|
||||
return reply
|
||||
.code(409)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
error instanceof GuardedInputConflict
|
||||
? error.message
|
||||
: `Stage delivery failed or ambiguous; ${MAY_BE_TYPED}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
app.post('/api/sessions/:id/submit', async (req, reply) => {
|
||||
const refused = await gate();
|
||||
if (refused) return reply.code(403).send(refused);
|
||||
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
|
||||
const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body);
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof GuardedInputConflict))
|
||||
console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`);
|
||||
return reply
|
||||
.code(409)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
error instanceof GuardedInputConflict
|
||||
? error.message
|
||||
: 'Submit delivery failed or ambiguous; receipt consumed, inspect the pane'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { ApprovalAnswerSchema } from '../schemas.js';
|
||||
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
|
||||
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
|
||||
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/**
|
||||
@@ -99,22 +100,23 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// Throws 404 (not 403) for sessions the caller does not own, same
|
||||
// no-existence-leak rule as every other session route.
|
||||
const session = findSessionOrFail(ctx, item.sessionId, req);
|
||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||
}
|
||||
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
|
||||
// a third-party TUI whose keystroke contract Codeman has not measured, the
|
||||
// Claude-shaped option parser never reads options off its frames, and
|
||||
// verifyStillAnswerable() can therefore never be conclusive for it — so the
|
||||
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
|
||||
// still raises the red alert and clears on the harness's own working/stop
|
||||
// reports; answering happens in the terminal.
|
||||
if (session.mode === 'deepseek') {
|
||||
// Alert-only items: the dialog belongs to a TUI whose keystroke contract Codeman
|
||||
// has not measured, so '1'/Esc would be a blind keystroke into a foreign composer.
|
||||
// Two sources today: a supervised-hook CLI (DeepSeek Harness), whose item clears on
|
||||
// the harness's own working/stop reports, and a dialog read off the screen of a
|
||||
// hook-less CLI (codex, web/screen-blocked.ts), which clears when it leaves the
|
||||
// screen and was read from text the agent writes, so it must never steer a key.
|
||||
// Checked BEFORE the hooks gate below: a codex session has no hooks, and "this mode
|
||||
// cannot have pending approvals" would be false about the item the caller holds.
|
||||
if (item.answerInTerminal) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
|
||||
`${getCli(session.mode)?.label ?? session.mode} approvals must be answered in the terminal: the dialog belongs to a TUI whose keystrokes Codeman cannot verify.`
|
||||
);
|
||||
}
|
||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||
}
|
||||
|
||||
// Re-capture the pane before aiming keystrokes at it: if the dialog was
|
||||
// answered in the terminal moments ago, the digit would land in whatever
|
||||
|
||||
+445
-28
@@ -10,7 +10,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { existsSync, lstatSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
|
||||
import { exec } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { join, resolve, basename, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type {
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
ClonePreflightSchema,
|
||||
LinkCaseSchema,
|
||||
CaseOrderSchema,
|
||||
CaseDefaultGroupSchema,
|
||||
RenameCaseSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
@@ -49,9 +51,20 @@ import {
|
||||
probeGitRemote,
|
||||
} from '../../git-clone.js';
|
||||
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
|
||||
import { isPathWithin } from '../../git-worktrees.js';
|
||||
import { defaultDockerWorkspaces } from './git-status-routes.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { prepareNewCasePath } from '../case-path.js';
|
||||
import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js';
|
||||
import {
|
||||
CaseRenameError,
|
||||
isCaseSessionStarting,
|
||||
moveCaseFolder,
|
||||
moveClaudeHistory,
|
||||
renameInOrder,
|
||||
siblingPath,
|
||||
withCaseLock,
|
||||
} from '../../case-rename.js';
|
||||
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
|
||||
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
|
||||
import {
|
||||
@@ -60,16 +73,25 @@ import {
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
requireAdmin,
|
||||
resolveCasesDir,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
} from '../route-helpers.js';
|
||||
import {
|
||||
assertSettingsWritable,
|
||||
mutateSettingsFile,
|
||||
SettingsWriteRefusedError,
|
||||
settingsWriteErrorResponse,
|
||||
} from '../settings-writer.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
|
||||
import type { EventPort, ConfigPort, SessionPort, TabLayoutPort } from '../ports/index.js';
|
||||
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
|
||||
import { TabLayoutValidationError } from '../../tab-layout.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
@@ -130,6 +152,10 @@ const APP_VERSION = (() => {
|
||||
const LOCAL_CLONE_ADMIN_ONLY =
|
||||
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
|
||||
|
||||
/** Refusal text for a clone at or inside a Docker case workspace (git would run its hooks on the host). */
|
||||
const DOCKER_CLONE_REFUSAL =
|
||||
'Cloning is not available into a folder inside a Docker case workspace: a container can write there, and git would run the new repository’s hooks and filters on this machine. Clone outside the workspace and link it, or clone from inside the container.';
|
||||
|
||||
/**
|
||||
* Whether a clone or preflight must run with git's credential helpers cleared:
|
||||
* a non-admin in multi-user mode. Every user's git runs as the one server
|
||||
@@ -245,7 +271,33 @@ async function ensureCaseImage(
|
||||
return { ok: true, imageBuilding: true };
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
|
||||
/**
|
||||
* Is `name` a case the caller can see (the same rules as GET /api/cases and
|
||||
* resolveCasePath): an owned remote or Docker case, a linked case (admin or
|
||||
* single-user only), or a directory in the caller's own case space. Bounded:
|
||||
* a case folder on a mount that stopped answering still counts as there, like
|
||||
* the case list keeps listing it.
|
||||
*/
|
||||
async function caseVisibleToCaller(name: string, req: FastifyRequest): Promise<boolean> {
|
||||
if (!SAFE_CASE_NAME.test(name)) return false;
|
||||
const user = getAuthUser(req);
|
||||
if ((await readRemoteCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
|
||||
return true;
|
||||
}
|
||||
if ((await readDockerCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
|
||||
return true;
|
||||
}
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req)))
|
||||
return (await probePath(linkedCases[name])) !== 'absent';
|
||||
const kind = await probePathKind(join(resolveCasesDir(user), name));
|
||||
return kind === 'directory' || kind === 'unknown';
|
||||
}
|
||||
|
||||
export function registerCaseRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: EventPort & ConfigPort & SessionPort & TabLayoutPort
|
||||
): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -615,7 +667,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
* request died mid-clone still sees the case appear over SSE when git finishes.
|
||||
*
|
||||
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
|
||||
* this writes only inside the caller's own `resolveCasesDir`. Two things would
|
||||
* this writes only inside the caller's own `resolveCasesDir`, EXCEPT with a `path` (a custom folder,
|
||||
* registered as a linked case in the shared registry), which is admin-only like Link Existing. Two things would
|
||||
* otherwise read through that boundary: a `local`-transport source (refused for
|
||||
* non-admins) and the server account's git credential helpers, which every user
|
||||
* shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`).
|
||||
@@ -640,7 +693,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
warnings: string[];
|
||||
}>
|
||||
> => {
|
||||
const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body);
|
||||
const { name, repository, ref, shallow, description, path: customPath } = parseBody(CloneCaseSchema, req.body);
|
||||
const user = getAuthUser(req);
|
||||
|
||||
const parsed = parseGitRepositoryUrl(repository);
|
||||
@@ -659,8 +712,59 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
);
|
||||
}
|
||||
|
||||
// A custom folder writes outside the cases directory and registers the path in the shared,
|
||||
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
|
||||
if (customPath !== undefined) {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
}
|
||||
|
||||
const casesDir = resolveCasesDir(user);
|
||||
const casePath = validatePathWithinBase(name, casesDir);
|
||||
let casePath: string | null;
|
||||
let intoEmptyDir = false;
|
||||
if (customPath !== undefined) {
|
||||
const prepared = await prepareNewCasePath(customPath, {
|
||||
home: homedir(),
|
||||
dataDir: getDataDir(),
|
||||
casesDirs: [...new Set([casesDir, resolveCasesDir()])],
|
||||
casesDirAdvice: 'clone there without a custom folder',
|
||||
});
|
||||
if (!prepared.ok) {
|
||||
reply.code(
|
||||
prepared.code === 'UNREACHABLE'
|
||||
? 422
|
||||
: prepared.code === 'NOT_FOUND'
|
||||
? 404
|
||||
: prepared.code === 'EXISTS'
|
||||
? 409
|
||||
: 400
|
||||
);
|
||||
return createErrorResponse(
|
||||
prepared.code === 'UNREACHABLE'
|
||||
? ApiErrorCode.OPERATION_FAILED
|
||||
: prepared.code === 'NOT_FOUND'
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: prepared.code === 'EXISTS'
|
||||
? ApiErrorCode.ALREADY_EXISTS
|
||||
: ApiErrorCode.INVALID_INPUT,
|
||||
prepared.code === 'EXISTS'
|
||||
? `${customPath} already has files in it. Pick an empty folder, or a folder that does not exist yet.`
|
||||
: prepared.reason
|
||||
);
|
||||
}
|
||||
intoEmptyDir = prepared.existedEmpty;
|
||||
casePath = prepared.path;
|
||||
// The same folder listed as a local case in the cases dir would make deleting it remove files.
|
||||
if (existsSync(join(casesDir, name))) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
'A case with this name already exists in codeman-cases.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
casePath = validatePathWithinBase(name, casesDir);
|
||||
}
|
||||
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
|
||||
// Reject a duplicate name across EVERY case kind before invoking git, so a
|
||||
@@ -669,8 +773,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const linkedAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
|
||||
if (linkedAs && customPath !== undefined) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${linkedAs}"`);
|
||||
}
|
||||
if (
|
||||
existsSync(casePath) ||
|
||||
// A custom folder was judged by prepareNewCasePath (an existing empty one is fine).
|
||||
(customPath === undefined && existsSync(casePath)) ||
|
||||
linkedCases[name] ||
|
||||
dockerCases.some((item) => item.name === name) ||
|
||||
remoteCases.some((item) => item.name === name)
|
||||
@@ -678,16 +788,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// git creates the leaf, not necessarily the case space above it.
|
||||
// git creates the leaf, not necessarily the case space above it (a custom folder's parent
|
||||
// already exists: prepareNewCasePath refused a missing one).
|
||||
if (customPath === undefined) {
|
||||
try {
|
||||
mkdirSync(casesDir, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
// git runs the new repository's hooks and filters on THIS machine once the clone finishes, and a container
|
||||
// with the workspace bind-mounted can write both while it fetches. Like git status, fetch and worktrees,
|
||||
// never clone at or inside a Docker case workspace. Fails closed when the Docker case list cannot be read.
|
||||
try {
|
||||
mkdirSync(casesDir, { recursive: true });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
// Probed first (#516): a case's workspace can sit on a mount that stopped answering, and an unbounded
|
||||
// realpath there would hold this request and a threadpool worker. Such a root is judged as spelled.
|
||||
const roots = await Promise.all(
|
||||
(await defaultDockerWorkspaces()).map(async (root) =>
|
||||
(await probePathKind(root, { pastCap: true })) === 'unknown'
|
||||
? resolve(root)
|
||||
: fs.realpath(root).catch(() => resolve(root))
|
||||
)
|
||||
);
|
||||
// A custom folder is already symlink-resolved; the default one is judged through its real cases dir.
|
||||
const dest =
|
||||
customPath !== undefined ? casePath : join(await fs.realpath(casesDir).catch(() => casesDir), name);
|
||||
if (roots.some((root) => isPathWithin(dest, root))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_CLONE_REFUSAL);
|
||||
}
|
||||
} catch {
|
||||
reply.code(503);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Could not read the Docker case list, so Codeman cannot tell whether that folder is a Docker case workspace. Try again.'
|
||||
);
|
||||
}
|
||||
|
||||
const clone = await cloneRepository({
|
||||
repository: parsed.repository,
|
||||
destination: casePath,
|
||||
...(intoEmptyDir ? { intoEmptyDir: true } : {}),
|
||||
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
|
||||
...(ref ? { ref } : {}),
|
||||
...(shallow ? { shallow: true } : {}),
|
||||
@@ -748,6 +889,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
if (customPath !== undefined) {
|
||||
// Re-read right before writing so a case linked while git ran is not dropped. Like
|
||||
// POST /api/cases/link the write is not serialized, so this only narrows the window.
|
||||
try {
|
||||
const fresh = await readLinkedCases();
|
||||
if (fresh[name])
|
||||
throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true });
|
||||
fresh[name] = casePath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||
} catch (err) {
|
||||
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
|
||||
const conflict = (err as { conflict?: boolean }).conflict;
|
||||
reply.code(conflict ? 409 : 500);
|
||||
return createErrorResponse(
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
|
||||
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
return {
|
||||
success: true,
|
||||
@@ -1536,7 +1697,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body);
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
|
||||
@@ -1583,6 +1744,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const user = getAuthUser(req);
|
||||
// The caller's default session group for this case goes with it (best-effort:
|
||||
// a stale value is ignored at creation anyway).
|
||||
// An admin deleting another user's remote/Docker case clears that owner's too.
|
||||
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
|
||||
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
|
||||
for (const owner of owners) {
|
||||
try {
|
||||
await ctx.tabLayouts.forgetCaseDefaultGroup(owner, name);
|
||||
} catch {
|
||||
/* never blocks the delete */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
@@ -1592,12 +1766,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// docker case is skipped so control falls through to the caller's own local delete.
|
||||
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
|
||||
const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
|
||||
if (remoteCase) {
|
||||
await writeRemoteCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
|
||||
);
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
|
||||
await forgetCaseDefault(remoteCase.owner);
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
@@ -1630,6 +1806,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
.catch(() => {});
|
||||
}
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
|
||||
await forgetCaseDefault(dockerCase.owner);
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
@@ -1642,6 +1819,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
|
||||
await forgetCaseDefault();
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
@@ -1657,34 +1835,273 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
try {
|
||||
await fs.rm(casePath, { recursive: true, force: true });
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
|
||||
await forgetCaseDefault();
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Default session group ==========
|
||||
|
||||
/**
|
||||
* The tab group new sessions of this case land in when the create request names
|
||||
* no group and no parent places them. Scoped to the caller's own tab layout: the
|
||||
* options are their groups, and a stored id that is no longer one of them reads
|
||||
* as Ungrouped (null); the read writes nothing (the next creation for the case
|
||||
* forgets it). Not admin-gated in multi-user mode: the
|
||||
* value is per owner (like the layout it points into), never server-wide.
|
||||
*/
|
||||
app.get('/api/cases/:name/default-group', async (req, reply) => {
|
||||
const { name } = req.params as { name: string };
|
||||
if (!(await caseVisibleToCaller(name, req))) {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
|
||||
}
|
||||
const view = await ctx.tabLayouts.caseDefaultGroup(ownerLayoutKey(ownerFor(req)), name);
|
||||
return { success: true, data: { caseName: name, ...view } };
|
||||
});
|
||||
|
||||
app.put('/api/cases/:name/default-group', async (req, reply) => {
|
||||
const { name } = req.params as { name: string };
|
||||
const { groupId } = parseBody(CaseDefaultGroupSchema, req.body, 'Invalid default group');
|
||||
if (!(await caseVisibleToCaller(name, req))) {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
|
||||
}
|
||||
try {
|
||||
const view = await ctx.tabLayouts.setCaseDefaultGroup(ownerLayoutKey(ownerFor(req)), name, groupId);
|
||||
return { success: true, data: { caseName: name, ...view } };
|
||||
} catch (err) {
|
||||
if (err instanceof TabLayoutValidationError) {
|
||||
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'That tab group does not exist'));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Rename Case ==========
|
||||
|
||||
/**
|
||||
* Rename a case: its name and, with it, its folder.
|
||||
*
|
||||
* - A case under the cases directory IS its folder name, so it always moves.
|
||||
* - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only
|
||||
* with `renameFolder: true` (it can be shared with other tools).
|
||||
* - Remote and Docker cases are not renamable here: their folder lives on another host or in a container
|
||||
* whose name derives from the case name.
|
||||
*
|
||||
* ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live
|
||||
* sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which
|
||||
* case they are ended first. The folder is held by an in-process lock for the whole operation, so a
|
||||
* session cannot start in it half-moved.
|
||||
*/
|
||||
app.post('/api/cases/:name/rename', async (req, reply) => {
|
||||
const { name } = req.params as { name: string };
|
||||
const user = getAuthUser(req);
|
||||
const body = parseBody(RenameCaseSchema, req.body);
|
||||
const newName = body.newName;
|
||||
const fail = (status: number, code: ApiErrorCode, message: string, extra: Record<string, unknown> = {}) => {
|
||||
reply.code(status);
|
||||
return { ...createErrorResponse(code, message), ...extra };
|
||||
};
|
||||
if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) {
|
||||
return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one');
|
||||
|
||||
// Remote and Docker cases first, so a same-named local case cannot be confused for them.
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) ||
|
||||
dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner))
|
||||
) {
|
||||
return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here');
|
||||
}
|
||||
|
||||
const casesBase = resolveCasesDir(user);
|
||||
const linkedCases = await readLinkedCases();
|
||||
const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req));
|
||||
const oldPath = isLinked ? linkedCases[name] : join(casesBase, name);
|
||||
|
||||
// The new name must be free in every namespace the list merges.
|
||||
const localTaken = existsSync(join(casesBase, newName));
|
||||
const taken =
|
||||
localTaken ||
|
||||
Boolean(linkedCases[newName]) ||
|
||||
remoteCases.some((c) => c.name === newName) ||
|
||||
dockerCases.some((c) => c.name === newName);
|
||||
if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`);
|
||||
|
||||
const state = await probePath(oldPath);
|
||||
if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||
// An alias-only rename never touches the folder, so an unreachable one does not block it.
|
||||
if (state === 'unknown' && (!isLinked || body.renameFolder === true)) {
|
||||
return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true }));
|
||||
}
|
||||
|
||||
const moveFolder = !isLinked || body.renameFolder === true;
|
||||
const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName);
|
||||
if (moveFolder && newPath !== oldPath) {
|
||||
// probePath follows symlinks, so a dangling one reads as absent; moveCaseFolder would still refuse it
|
||||
// (it checks with lstat), but only after the sessions below were stopped. The probe answered for this
|
||||
// very path, so the lstat after it is not a first touch of an unreachable mount.
|
||||
const target = await probePath(newPath, { pastCap: true });
|
||||
const danglingLink =
|
||||
target === 'absent' &&
|
||||
(await fs.lstat(newPath).then(
|
||||
() => true,
|
||||
() => false
|
||||
));
|
||||
if (target !== 'absent' || danglingLink) {
|
||||
return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running.
|
||||
// Remote sessions carry the remote path as their workingDir, so they are not this folder's.
|
||||
const heldRoot = resolve(oldPath);
|
||||
const open = moveFolder
|
||||
? Array.from(ctx.sessions.values()).filter((s) => {
|
||||
if (s.remote) return false;
|
||||
const dir = resolve(s.workingDir);
|
||||
return dir === heldRoot || dir.startsWith(heldRoot + sep);
|
||||
})
|
||||
: [];
|
||||
if (open.some((s) => !canAccessOwned(user, s.owner))) {
|
||||
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
|
||||
}
|
||||
// The admin's rename writes settings.json only after the sessions are ended and the folder moved, and
|
||||
// the writer refuses a file that is not a JSON object: check it by the writer's own rule now, so no
|
||||
// session is ended for a rename that cannot complete. Not between the starting check and the lock below.
|
||||
if (isAdmin(req)) {
|
||||
try {
|
||||
await assertSettingsWritable();
|
||||
} catch (err) {
|
||||
if (err instanceof SettingsWriteRefusedError) return fail(409, ApiErrorCode.CONFLICT, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor
|
||||
// stopped: it would start in the old path after the move. Checked in the same tick that takes the lock
|
||||
// below (nothing is awaited in between), and the Session constructor refuses the folder from then on.
|
||||
if (moveFolder && isCaseSessionStarting(oldPath)) {
|
||||
return fail(409, ApiErrorCode.CONFLICT, 'A session is starting in this case. Try again in a moment.', {
|
||||
renameCode: 'BUSY',
|
||||
});
|
||||
}
|
||||
if (open.length > 0 && !body.stopSessions) {
|
||||
return fail(
|
||||
409,
|
||||
ApiErrorCode.CONFLICT,
|
||||
`${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`,
|
||||
{ requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) }
|
||||
);
|
||||
}
|
||||
|
||||
// Sessions really ended so far: an error after `stopSessions` still says how many are gone.
|
||||
let stopped = 0;
|
||||
try {
|
||||
await withCaseLock(oldPath, newPath, async () => {
|
||||
for (const s of open) {
|
||||
await ctx.cleanupSession(s.id, true, 'case_rename');
|
||||
stopped++;
|
||||
}
|
||||
if (moveFolder) await moveCaseFolder(oldPath, newPath);
|
||||
const originalLinks = isLinked ? await readLinkedCases() : undefined;
|
||||
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
|
||||
// these keys alone, so a settings save that landed in between is kept.
|
||||
let settingsUndo: Record<string, unknown> | undefined;
|
||||
try {
|
||||
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
|
||||
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
|
||||
// Written before linked-cases.json so a failure here leaves the registry untouched.
|
||||
if (isAdmin(req)) {
|
||||
const { before, after } = await mutateSettingsFile((existing) => {
|
||||
const order = renameInOrder(existing.caseOrder, name, newName);
|
||||
const last = existing.lastUsedCase === name ? newName : undefined;
|
||||
if (!order && !last) return undefined;
|
||||
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
|
||||
});
|
||||
if (after) {
|
||||
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
}
|
||||
}
|
||||
if (originalLinks) {
|
||||
const links = { ...originalLinks };
|
||||
delete links[name];
|
||||
links[newName] = newPath;
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
|
||||
} catch (err) {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// The folder moved but the registries did not follow: put settings.json and the folder back
|
||||
// rather than leave a case whose name and folder disagree.
|
||||
const undo = settingsUndo;
|
||||
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
|
||||
if (moveFolder) {
|
||||
const movedBack = await fs.rename(newPath, oldPath).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (!movedBack) {
|
||||
throw new Error(`${getErrorMessage(err)}. The folder could not be moved back and is now at ${newPath}.`);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (moveFolder) await moveClaudeHistory(oldPath, newPath);
|
||||
// The owner's default session group is keyed by case name: carry it over, best effort.
|
||||
const layoutOwner = ownerLayoutKey(ownerFor(req));
|
||||
try {
|
||||
const { groupId } = await ctx.tabLayouts.caseDefaultGroup(layoutOwner, name);
|
||||
if (groupId) await ctx.tabLayouts.setCaseDefaultGroup(layoutOwner, newName, groupId);
|
||||
} catch {
|
||||
/* the group is a convenience; the rename already happened */
|
||||
}
|
||||
await ctx.tabLayouts.forgetCaseDefaultGroup(layoutOwner, name).catch(() => undefined);
|
||||
});
|
||||
ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder });
|
||||
return {
|
||||
success: true,
|
||||
data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length },
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof CaseRenameError) {
|
||||
const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400;
|
||||
const code =
|
||||
err.code === 'EXISTS'
|
||||
? ApiErrorCode.ALREADY_EXISTS
|
||||
: err.code === 'BUSY'
|
||||
? ApiErrorCode.CONFLICT
|
||||
: ApiErrorCode.OPERATION_FAILED;
|
||||
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
|
||||
}
|
||||
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Reorder Cases ==========
|
||||
|
||||
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||||
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
|
||||
// `caseOrder` is ONE list in the server-wide settings.json, read by every user's
|
||||
// GET /api/cases, so a non-admin's reorder would replace everyone's order (and
|
||||
// publish their case names to every user through GET /api/settings). Admin-only in
|
||||
// multi-user mode, like PUT /api/settings; a per-user order would need per-user
|
||||
// storage. No-op in single-user mode.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
||||
|
||||
try {
|
||||
const dir = getDataDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const merged = { ...existing, caseOrder: order };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
await mutateSettingsFile((current) => ({ ...current, caseOrder: order }));
|
||||
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
|
||||
return { success: true, data: { order } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @fileoverview Config backups (src/config-backup.ts).
|
||||
*
|
||||
* GET /api/config-backups — settings in effect, the folder, and the snapshots in it.
|
||||
* POST /api/config-backups — snapshot now (a manual request always makes one).
|
||||
* POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot).
|
||||
* GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the
|
||||
* running config (`current`, the default for `to`).
|
||||
*
|
||||
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all four
|
||||
* are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows
|
||||
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks
|
||||
* like a token/secret/password are reported as "changed" only). Restore replaces files the running server
|
||||
* may have cached: `restartRequired` says which ones only apply after a restart. A restore that could not
|
||||
* write every file answers 500 with the error envelope plus `data` (`restored`, `failed`, `untouched`,
|
||||
* `safetySnapshot`, `restartRequired`): the files it wrote stay restored and are reloaded like a full one.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { getAuthUser, isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { reloadCliRegistry } from '../../config/cli-registry/registry.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { intentStore } from '../../intent-store.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
|
||||
import { dirname } from 'node:path';
|
||||
import {
|
||||
CONFIG_BACKUP_FILES,
|
||||
CONFIG_DIFF_CURRENT,
|
||||
ConfigBackupCorruptError,
|
||||
ConfigBackupNotFoundError,
|
||||
ConfigBackupPartialRestoreError,
|
||||
defaultConfigBackupDir,
|
||||
diffConfigBackups,
|
||||
listConfigBackups,
|
||||
readConfigBackupSettings,
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
runConfigBackupPass,
|
||||
withRecordedRetention,
|
||||
readConfigBackupOrigin,
|
||||
type ConfigBackupResult,
|
||||
type ConfigBackupSettings,
|
||||
type ConfigBackupSummary,
|
||||
type ConfigDiff,
|
||||
} from '../../config-backup.js';
|
||||
|
||||
// Only recorded in each manifest; a missing package.json just leaves it blank.
|
||||
const APP_VERSION: string = (() => {
|
||||
try {
|
||||
return (createRequire(import.meta.url)('../../../package.json') as { version?: string }).version ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
/** Files whose running-process copy only reloads on restart (the rest are read per request; `users.json`
|
||||
* is re-read within a second, `clis.json` is reloaded below, `intents.json` by `reload()`). */
|
||||
const RESTART_REQUIRED = new Set<string>(['.env', 'push-keys.json', 'settings.json']);
|
||||
|
||||
export interface ConfigBackupStatus {
|
||||
settings: ConfigBackupSettings;
|
||||
dir: string;
|
||||
defaultDir: string;
|
||||
tracked: readonly string[];
|
||||
backups: ConfigBackupSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings in effect and the folder they name. A custom folder is probed (bounded, #516) BEFORE
|
||||
* the recorded-retention fallback reads it, so this throws `BackupDirUnavailableError` for a folder
|
||||
* that does not answer and never lists it; `present` is false for a custom folder that does not exist yet.
|
||||
*/
|
||||
export async function readEffectiveBackupSettings(): Promise<{
|
||||
settings: ConfigBackupSettings;
|
||||
dir: string;
|
||||
present: boolean;
|
||||
}> {
|
||||
const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
const base = readConfigBackupSettings(raw);
|
||||
const dir = resolveConfigBackupDir(base, getDataDir());
|
||||
const present = await probeBackupDir(base, dir);
|
||||
// The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0.
|
||||
const settings = present ? withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())) : base;
|
||||
return { settings, dir, present };
|
||||
}
|
||||
|
||||
/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */
|
||||
export class BackupDirUnavailableError extends Error {}
|
||||
|
||||
/**
|
||||
* Probe a custom backup folder without blocking the event loop on a dead mount. The default folder
|
||||
* needs no probe. Returns whether the folder exists; throws when it did not answer, or is not a
|
||||
* folder. A missing custom folder is only creatable when its parent is present.
|
||||
*/
|
||||
export async function probeBackupDir(settings: ConfigBackupSettings, dir: string): Promise<boolean> {
|
||||
if (!settings.dir || dir === defaultConfigBackupDir(getDataDir())) return true;
|
||||
const kind = await probePathKind(dir, { pastCap: true });
|
||||
if (kind === 'unknown')
|
||||
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dir, { pastCap: true }));
|
||||
if (kind === 'file') throw new BackupDirUnavailableError(`Backup folder is a file, not a folder: ${dir}`);
|
||||
if (kind === 'directory') return true;
|
||||
const parent = await probePathKind(dirname(dir), { pastCap: true });
|
||||
if (parent === 'unknown') {
|
||||
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dirname(dir), { pastCap: true }));
|
||||
}
|
||||
if (parent !== 'directory') throw new BackupDirUnavailableError(`Backup folder's parent is missing: ${dirname(dir)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readSettingsForBackup(): Promise<Record<string, unknown> | 'unreadable'> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(SETTINGS_PATH, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: 'unreadable';
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === 'ENOENT' ? {} : 'unreadable';
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */
|
||||
export async function runConfigBackup(
|
||||
reason: string,
|
||||
opts: { force?: boolean } = {}
|
||||
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
|
||||
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error (or, before
|
||||
// settings writes became atomic, a read that landed inside one) used to prune with the defaults (20 / 30
|
||||
// days) and drop the longer history the user asked to keep.
|
||||
const raw = await readSettingsForBackup();
|
||||
const unreadable = raw === 'unreadable';
|
||||
const fromFile = readConfigBackupSettings(unreadable ? {} : raw);
|
||||
if (!fromFile.enabled && !opts.force) return null;
|
||||
const dir = resolveConfigBackupDir(fromFile, getDataDir());
|
||||
await probeBackupDir(fromFile, dir);
|
||||
return runConfigBackupPass({
|
||||
dataDir: getDataDir(),
|
||||
backupDir: dir,
|
||||
reason,
|
||||
appVersion: APP_VERSION,
|
||||
force: opts.force,
|
||||
settings: fromFile,
|
||||
raw: unreadable ? {} : raw,
|
||||
// An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so.
|
||||
prune: !unreadable,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConfigBackupRouteDeps {
|
||||
/** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */
|
||||
reloadDiscord?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void {
|
||||
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
let eff: Awaited<ReturnType<typeof readEffectiveBackupSettings>>;
|
||||
try {
|
||||
eff = await readEffectiveBackupSettings();
|
||||
} catch (err) {
|
||||
reply.code(503);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
const { settings, dir, present } = eff;
|
||||
// Read-only: a listing must not create config-backup-id, and without one nothing can be ours.
|
||||
const origin = readConfigBackupOrigin(getDataDir());
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
settings,
|
||||
dir,
|
||||
defaultDir: defaultConfigBackupDir(getDataDir()),
|
||||
tracked: CONFIG_BACKUP_FILES,
|
||||
backups: present && origin ? listConfigBackups(dir, origin) : [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/config-backups', async (req, reply) => {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
try {
|
||||
const out = await runConfigBackup('manual', { force: true });
|
||||
return { success: true, data: { ...out!.result, removed: out!.removed } };
|
||||
} catch (err) {
|
||||
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config-backups/diff', async (req, reply): Promise<ApiResponse<ConfigDiff>> => {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
const q = req.query as { from?: string; to?: string };
|
||||
if (typeof q.from !== 'string' || !q.from) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
|
||||
}
|
||||
try {
|
||||
const { dir } = await readEffectiveBackupSettings();
|
||||
const data = diffConfigBackups({
|
||||
dataDir: getDataDir(),
|
||||
backupDir: dir,
|
||||
from: q.from,
|
||||
to: typeof q.to === 'string' && q.to ? q.to : CONFIG_DIFF_CURRENT,
|
||||
});
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigBackupNotFoundError) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
|
||||
}
|
||||
if (err instanceof ConfigBackupCorruptError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* What a restore that wrote files must do, whether or not every file made it: reload what the running
|
||||
* server caches, audit it, and name the files that only apply after a restart.
|
||||
*/
|
||||
const afterRestore = (req: FastifyRequest, id: string, restored: string[], failed: string[] = []): string[] => {
|
||||
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
|
||||
if (restored.includes('intents.json')) intentStore.reload();
|
||||
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
|
||||
if (restored.includes('clis.json')) reloadCliRegistry();
|
||||
// The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge.
|
||||
if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined);
|
||||
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
|
||||
if (isMultiUserMode()) {
|
||||
void appendAdminAudit({
|
||||
admin: getAuthUser(req).username,
|
||||
action: 'config_backup_restore',
|
||||
target: id,
|
||||
ip: req.ip,
|
||||
detail: { restored, ...(failed.length > 0 && { failed }) },
|
||||
});
|
||||
}
|
||||
return restored.filter((n) => RESTART_REQUIRED.has(n));
|
||||
};
|
||||
|
||||
app.post('/api/config-backups/:id/restore', async (req, reply) => {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
try {
|
||||
const { dir } = await readEffectiveBackupSettings();
|
||||
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
restored: out.restored,
|
||||
untouched: out.untouched,
|
||||
safetySnapshot: out.safetySnapshot,
|
||||
restartRequired: afterRestore(req, id, out.restored),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigBackupNotFoundError) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
|
||||
}
|
||||
if (err instanceof ConfigBackupCorruptError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
|
||||
}
|
||||
if (err instanceof ConfigBackupPartialRestoreError) {
|
||||
// Some files are the backup's now: the client must learn which (Settings re-reads a restored
|
||||
// settings.json, or its next Save writes the old values back) and where the undo copy is.
|
||||
const failedNames = err.failed.map((f) => f.name);
|
||||
const restartRequired = afterRestore(req, id, err.restored, failedNames);
|
||||
reply.code(500);
|
||||
return {
|
||||
...createErrorResponse(ApiErrorCode.OPERATION_FAILED, err.message),
|
||||
data: {
|
||||
restored: err.restored,
|
||||
failed: err.failed,
|
||||
untouched: err.untouched,
|
||||
safetySnapshot: err.safetySnapshot,
|
||||
restartRequired,
|
||||
},
|
||||
};
|
||||
}
|
||||
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { isBlockedWebviewUrl } from '../webview-egress-policy.js';
|
||||
import { egressBlockedReason, webviewFetch } from '../webview-egress.js';
|
||||
import { CustomModelHostSchema } from '../schemas.js';
|
||||
import { CustomModelHostSchema, MAX_DISCOVERED_CONTEXT_TOKENS } from '../schemas.js';
|
||||
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
|
||||
import type { CliEntry } from '../../config/cli-registry/types.js';
|
||||
|
||||
@@ -56,11 +56,31 @@ export const CLAUDE_MIN_SAFE_CONTEXT_TOKENS = 40000;
|
||||
*/
|
||||
export function exceedsSafeContextFloor(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
contextLength: number | undefined
|
||||
contextLength: number | undefined,
|
||||
slotCount?: number
|
||||
): boolean {
|
||||
const cap = entry.capabilities.customModelInjection;
|
||||
if (cap.kind !== 'env' || !cap.contextLengthVar) return false;
|
||||
return typeof contextLength === 'number' && contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
|
||||
if (typeof contextLength !== 'number') return false;
|
||||
if (contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS) return true;
|
||||
return perSlotContext(contextLength, slotCount) < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The context one request can rely on when the server runs `slotCount` parallel slots.
|
||||
* llama-server divides its context across `--parallel` slots (by default they share ONE
|
||||
* unified KV pool), and Claude Code sends concurrent requests: the main session plus every
|
||||
* parallel subagent, each carrying the full `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` of its own
|
||||
* system prompt and tools. With N busy slots each request therefore gets roughly
|
||||
* `contextLength / N`, and once the pool is spent llama.cpp answers HTTP 500 "Context size
|
||||
* has been exceeded" (measured: 4 slots, ~131k pool, four simultaneous 40k-token requests
|
||||
* all failed; the same requests against one slot all succeeded). An unknown or single slot
|
||||
* count leaves the context whole.
|
||||
*/
|
||||
export function perSlotContext(contextLength: number, slotCount: number | undefined): number {
|
||||
const slots =
|
||||
typeof slotCount === 'number' && Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1;
|
||||
return Math.floor(contextLength / slots);
|
||||
}
|
||||
|
||||
function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null {
|
||||
@@ -109,7 +129,7 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never
|
||||
* `modelContextLengths`/`modelSlotCounts`/`modelSizesGB` are server-populated by discovery, never
|
||||
* user-entered, and PUT replaces the whole record — so merge them back in from the
|
||||
* stored host rather than trust whatever the editor's body carried (or omitted).
|
||||
* The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached
|
||||
@@ -117,7 +137,12 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost)
|
||||
* future caller forgets, same class of bug `applyStoredApiKey` exists to prevent.
|
||||
*/
|
||||
function applyDiscoveredFields(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost {
|
||||
return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB };
|
||||
return {
|
||||
...incoming,
|
||||
modelContextLengths: existing.modelContextLengths,
|
||||
modelSlotCounts: existing.modelSlotCounts,
|
||||
modelSizesGB: existing.modelSizesGB,
|
||||
};
|
||||
}
|
||||
|
||||
function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Record<string, string> {
|
||||
@@ -133,8 +158,15 @@ function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Recor
|
||||
|
||||
export interface DiscoveryResult {
|
||||
models: string[];
|
||||
/** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */
|
||||
/**
|
||||
* See `CustomModelHost.modelContextLengths`: populated from a model's own `/v1/models`
|
||||
* entry (`context_length`, else `context_window`) whenever the server states one, and
|
||||
* from the launch command or `/props` for loaded models, which overwrite (and so win
|
||||
* over) any listed figure for the same model.
|
||||
*/
|
||||
contextLengths: Record<string, number>;
|
||||
/** See `CustomModelHost.modelSlotCounts`; populated only for models the server reports loaded (launch command or `/props`). No listing states a slot count, unlike `contextLengths`. */
|
||||
slotCounts: Record<string, number>;
|
||||
/** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */
|
||||
sizesGB: Record<string, number>;
|
||||
}
|
||||
@@ -155,9 +187,23 @@ function parseSizeGB(description: unknown): number | undefined {
|
||||
return Number.isFinite(size) && size > 0 ? size : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: reads a context length (tokens) stated by a model's OWN entry in
|
||||
* `GET /v1/models`. OpenRouter-style servers call the field `context_length`; current
|
||||
* llama-swap sends both `context_length` and `context_window` per model, and current
|
||||
* Bifrost (a gateway in front of llama-swap) passes `context_window` through. Older
|
||||
* servers send neither, and a server that states nothing gets no entry, never a guess.
|
||||
* Non-numeric, zero, negative, fractional or over-the-ceiling values are ignored, the
|
||||
* same rule the stored field's schema enforces: a wrong figure here is worse than none.
|
||||
*/
|
||||
function parseListedContextLength(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return undefined;
|
||||
return value <= MAX_DISCOVERED_CONTEXT_TOKENS ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for
|
||||
* ONE already-loaded model and pulls its real `n_ctx` out. Never called for a model that
|
||||
* ONE already-loaded model and pulls its `n_ctx` and `total_slots` out. Never called for a model that
|
||||
* isn't already loaded — see the caller and `CustomModelHost.modelContextLengths` for why
|
||||
* that's a hard safety requirement, not just a nicety: llama-swap treats this endpoint's
|
||||
* `?model=` as a routing hint, and asking it about an unloaded model risks triggering an
|
||||
@@ -173,20 +219,39 @@ function parseSizeGB(description: unknown): number | undefined {
|
||||
* (below), which reads the actual launch flag `/running` reports, is the primary source;
|
||||
* this is only used when that parse comes up empty (no recognized flag in `cmd`, or `cmd`
|
||||
* itself unavailable).
|
||||
*
|
||||
* ⚠️ `/props` only ever RAISES the slot warning, never skips it: its `n_ctx` is the context
|
||||
* fallback when `cmd` states no context flag at all, and `total_slots` is the slot count when
|
||||
* `cmd` has no `--parallel`. It never widens the pool the slot check divides. And it is not
|
||||
* free: llama-swap routes it through the model's process handler, which refreshes the model's
|
||||
* last-use time, so probing a model with a `ttl` on every 5-minute sweep would keep it loaded
|
||||
* forever. `discoverModels` therefore probes once per launch command (`propsByCmd`).
|
||||
*
|
||||
* Returns `undefined` when the request itself failed (network error, timeout, unreadable body),
|
||||
* which is retried next sweep; a non-2xx status, or a 2xx without the fields, gives an object with
|
||||
* them unset, which `propsForLaunch` remembers.
|
||||
*/
|
||||
async function fetchContextLength(
|
||||
async function fetchServerProps(
|
||||
host: Pick<CustomModelHost, 'baseUrl'>,
|
||||
modelId: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<number | undefined> {
|
||||
): Promise<{ nCtx?: number; totalSlots?: number } | undefined> {
|
||||
try {
|
||||
const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/props`);
|
||||
url.searchParams.set('model', modelId);
|
||||
const res = await webviewFetch(url, { headers, signal: AbortSignal.timeout(PROPS_TIMEOUT_MS) });
|
||||
if (!res.ok) return undefined;
|
||||
const body = (await res.json()) as { n_ctx?: unknown; default_generation_settings?: { n_ctx?: unknown } };
|
||||
// A non-2xx answer (an upstream that does not serve /props) is a real answer: empty, so it is
|
||||
// remembered per launch command instead of being re-probed every sweep.
|
||||
if (!res.ok) return {};
|
||||
const body = (await res.json()) as {
|
||||
n_ctx?: unknown;
|
||||
total_slots?: unknown;
|
||||
default_generation_settings?: { n_ctx?: unknown };
|
||||
};
|
||||
const nCtx = body.n_ctx ?? body.default_generation_settings?.n_ctx;
|
||||
return typeof nCtx === 'number' && Number.isFinite(nCtx) && nCtx > 0 ? nCtx : undefined;
|
||||
const positive = (v: unknown): number | undefined =>
|
||||
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
||||
return { nCtx: positive(nCtx), totalSlots: positive(body.total_slots) };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -196,7 +261,7 @@ async function fetchContextLength(
|
||||
* Parses the REAL configured context size out of llama-swap's own launch command for a
|
||||
* model (`/running`'s `cmd` field, e.g. `"llama-server -m ... --fit-ctx 16384 ..."`) —
|
||||
* the primary source for `modelContextLengths`, preferred over `/props`'s `n_ctx` (see
|
||||
* `fetchContextLength`'s own doc comment for why that field is unreliable here). Checks
|
||||
* `fetchServerProps`'s own doc comment for why that field is unreliable here). Checks
|
||||
* `--fit-ctx` first (llama-swap's own auto-fit flag), then the plain llama.cpp
|
||||
* `-c`/`--ctx-size`/`--ctx_size` flags a hand-written launch command might use instead.
|
||||
* Returns `undefined` when `cmd` has none of these — not every launch command needs to
|
||||
@@ -211,6 +276,51 @@ function parseCtxFromCmd(cmd: unknown): number | undefined {
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an explicit `--parallel N` / `-np N` (or `--parallel=N`) out of a launch command.
|
||||
* Returns `undefined` when the command states none, including `-np -1` (llama.cpp's "pick
|
||||
* automatically"), because a llama-server started without it chooses the slot count itself
|
||||
* (4 on the build this was measured on) and only `/props`'s `total_slots` can say what it chose.
|
||||
*/
|
||||
function parseSlotsFromCmd(cmd: unknown): number | undefined {
|
||||
if (typeof cmd !== 'string') return undefined;
|
||||
const match = /(?:^|\s)(?:-np|--parallel)(?:\s+|=)(\d+)(?=\s|$)/.exec(cmd);
|
||||
if (!match) return undefined;
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** The `/props` answer for the launch command a model was last probed under, by host and model id. */
|
||||
const propsByCmd = new Map<string, { cmd: string; props: { nCtx?: number; totalSlots?: number } }>();
|
||||
|
||||
/** Forget what was probed (tests only: a changed launch command already probes again). */
|
||||
export function clearPropsProbeCache(): void {
|
||||
propsByCmd.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* `/props` for one loaded model, but only once per launch command: the probe refreshes the model's
|
||||
* last-use time in llama-swap, so repeating it on every rediscovery sweep would stop a model with a
|
||||
* `ttl` from ever unloading. A changed `cmd` (a reloaded or reconfigured model) probes again; a
|
||||
* `cmd` that is not a string cannot be told apart, so it is probed each time; a probe that did not
|
||||
* answer (network error, timeout) is not remembered; a non-2xx status is. Held in memory only, so a restart probes once more.
|
||||
*/
|
||||
async function propsForLaunch(
|
||||
host: Pick<CustomModelHost, 'baseUrl'>,
|
||||
modelId: string,
|
||||
cmd: unknown,
|
||||
headers: Record<string, string>
|
||||
): Promise<{ nCtx?: number; totalSlots?: number } | undefined> {
|
||||
const key = `${host.baseUrl}\0${modelId}`;
|
||||
if (typeof cmd === 'string') {
|
||||
const cached = propsByCmd.get(key);
|
||||
if (cached?.cmd === cmd) return cached.props;
|
||||
}
|
||||
const props = await fetchServerProps(host, modelId, headers);
|
||||
if (props && typeof cmd === 'string') propsByCmd.set(key, { cmd, props });
|
||||
return props;
|
||||
}
|
||||
|
||||
async function discoverModels(
|
||||
host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>
|
||||
): Promise<DiscoveryResult> {
|
||||
@@ -221,24 +331,42 @@ async function discoverModels(
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as {
|
||||
data?: Array<{ id?: unknown; status?: { value?: unknown }; description?: unknown }>;
|
||||
data?: Array<{
|
||||
id?: unknown;
|
||||
status?: { value?: unknown };
|
||||
description?: unknown;
|
||||
context_length?: unknown;
|
||||
context_window?: unknown;
|
||||
}>;
|
||||
};
|
||||
const entries = body.data ?? [];
|
||||
const models = entries.map((m) => m.id).filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
|
||||
const sizesGB: Record<string, number> = {};
|
||||
// The lowest-precedence context source, and the only one a gateway without /running or
|
||||
// /props of its own (e.g. Bifrost) can offer: the figure a model's own listing states
|
||||
// (`context_length`, else `context_window`). The loaded-model pass below OVERWRITES
|
||||
// these for whatever it reads from the launch command or /props, so precedence per
|
||||
// model stays: `/running` cmd, then `/props`, then this listed figure.
|
||||
const contextLengths: Record<string, number> = {};
|
||||
for (const entry of entries) {
|
||||
if (typeof entry.id !== 'string' || !entry.id) continue;
|
||||
const size = parseSizeGB(entry.description);
|
||||
if (size !== undefined) sizesGB[entry.id] = size;
|
||||
const listed = parseListedContextLength(entry.context_length) ?? parseListedContextLength(entry.context_window);
|
||||
if (listed !== undefined) contextLengths[entry.id] = listed;
|
||||
}
|
||||
|
||||
// llama-swap-specific, feature-detected: a server that never mentions `status` on ANY
|
||||
// entry gets no context-length enrichment at all, rather than treating "no status field"
|
||||
// as "assume unloaded" — either reading is a guess, and skipping is the safe one, since
|
||||
// fetchContextLength must only ever run against a model this server itself calls loaded.
|
||||
// entry gets no launch-command/props context enrichment at all, rather than treating
|
||||
// "no status field" as "assume unloaded"; either reading is a guess, and skipping is
|
||||
// the safe one, since fetchContextLength must only ever run against a model this server
|
||||
// itself calls loaded. Listed figures above are unaffected: reading them triggers nothing.
|
||||
const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m);
|
||||
const contextLengths: Record<string, number> = {};
|
||||
const slotCounts: Record<string, number> = {};
|
||||
// Ids whose figure THIS round's loaded-model probe answered: the listed figure remains
|
||||
// the source only for the ids NOT in this set (see the per-slot correction below).
|
||||
const ctxFromProbe = new Set<string>();
|
||||
if (hasStatusField) {
|
||||
const loadedIds = entries
|
||||
.filter((m) => m.status && typeof m.status === 'object' && (m.status as { value?: unknown }).value === 'loaded')
|
||||
@@ -252,13 +380,51 @@ async function discoverModels(
|
||||
const swapStatus = await getLlamaSwapStatus(host);
|
||||
const cmdById = new Map(swapStatus.running.map((r) => [r.model, r.cmd]));
|
||||
for (const id of loadedIds) {
|
||||
const fromCmd = parseCtxFromCmd(cmdById.get(id));
|
||||
const ctx = fromCmd ?? (await fetchContextLength(host, id, headers));
|
||||
if (ctx !== undefined) contextLengths[id] = ctx;
|
||||
const cmd = cmdById.get(id);
|
||||
let ctx = parseCtxFromCmd(cmd);
|
||||
const cmdSlots = parseSlotsFromCmd(cmd);
|
||||
let slots = cmdSlots;
|
||||
// /props is only needed for whatever the launch command did not answer. The context
|
||||
// still comes from the command whenever it states one (see fetchServerProps's doc
|
||||
// comment for why /props's n_ctx is only a fallback); the slot count usually cannot
|
||||
// come from the command at all, since an unset --parallel is chosen by the server.
|
||||
if (ctx === undefined || slots === undefined) {
|
||||
const props = await propsForLaunch(host, id, cmd, headers);
|
||||
const ctxFromProps = ctx === undefined && props?.nCtx !== undefined;
|
||||
ctx ??= props?.nCtx;
|
||||
slots ??= props?.totalSlots;
|
||||
// With an explicit --parallel N, llama.cpp reports /props's n_ctx PER SLOT (the unified
|
||||
// KV pool is only the default when the count is auto), so it is already the figure one
|
||||
// request can use, so the count is 1: recording N would divide it a second time, and
|
||||
// recording nothing would leave an earlier round's count in place (applyDiscoveredModels
|
||||
// merges onto what was learned before).
|
||||
if (cmdSlots !== undefined && ctxFromProps) slots = 1;
|
||||
}
|
||||
// Assigning only when a probe answered keeps the listed figure (if any) when
|
||||
// neither the launch command nor /props stated one, and replaces it with the
|
||||
// stronger source whenever they did: cmd > /props > listed, never the reverse.
|
||||
if (ctx !== undefined) {
|
||||
contextLengths[id] = ctx;
|
||||
ctxFromProbe.add(id);
|
||||
}
|
||||
if (slots !== undefined) slotCounts[id] = slots;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { models, contextLengths, sizesGB };
|
||||
// llama-swap documents the LISTED `context_length` as the PER-SLOT window, so whenever
|
||||
// the listed figure is the one taken this round (the model was not loaded, or the
|
||||
// probe answered nothing), its slot count is 1 by definition: the floor check must NOT
|
||||
// divide a per-slot figure a second time. Without recording 1 here, the count a loaded
|
||||
// round stored survives the merge (applyDiscoveredModels keeps what a later round does
|
||||
// not overwrite): 262144 @ 4 slots stored, model unloaded, listed 65536 → 65536 with 4
|
||||
// slots → a false 16384-per-slot warning. Even a count this round's launch command
|
||||
// parsed must yield: pairing a per-slot listed figure with the cmd's --parallel is the
|
||||
// same double division. Same rule the /props branch above applies. Ids whose figure the
|
||||
// probe DID answer keep their probe-derived count untouched.
|
||||
for (const id of Object.keys(contextLengths)) {
|
||||
if (!ctxFromProbe.has(id)) slotCounts[id] = 1;
|
||||
}
|
||||
return { models, contextLengths, slotCounts, sizesGB };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,7 +739,7 @@ export function triggerLlamaSwapLoad(
|
||||
}
|
||||
|
||||
function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult): CustomModelHost {
|
||||
const { models, contextLengths, sizesGB } = result;
|
||||
const { models, contextLengths, slotCounts, sizesGB } = result;
|
||||
const defaultModelId = host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined;
|
||||
// Merge onto what's already known rather than replacing: a model not probed this round
|
||||
// (not currently loaded) keeps whatever context length an earlier round already learned
|
||||
@@ -581,6 +747,10 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult):
|
||||
const merged = { ...host.modelContextLengths, ...contextLengths };
|
||||
const kept = Object.fromEntries(Object.entries(merged).filter(([id]) => models.includes(id)));
|
||||
const modelContextLengths = Object.keys(kept).length > 0 ? kept : undefined;
|
||||
// Slot counts follow the same loaded-only, keep-what-an-earlier-round-learned rule.
|
||||
const mergedSlots = { ...host.modelSlotCounts, ...slotCounts };
|
||||
const keptSlots = Object.fromEntries(Object.entries(mergedSlots).filter(([id]) => models.includes(id)));
|
||||
const modelSlotCounts = Object.keys(keptSlots).length > 0 ? keptSlots : undefined;
|
||||
// sizesGB, unlike contextLengths, is populated for every model in the SAME pass (no
|
||||
// loaded-only restriction — see parseSizeGB), so this is closer to a plain replace, but
|
||||
// still merges onto the previous round rather than dropping a size for a model whose
|
||||
@@ -593,6 +763,7 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult):
|
||||
models,
|
||||
defaultModelId,
|
||||
modelContextLengths,
|
||||
modelSlotCounts,
|
||||
modelSizesGB,
|
||||
lastDiscoveredAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts).
|
||||
*
|
||||
* GET /api/discord the config WITHOUT its token, plus gateway state and last result
|
||||
* PUT /api/discord change any field; an empty `token` clears it
|
||||
* POST /api/discord/test post one test message to the parent channel
|
||||
* POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch)
|
||||
*
|
||||
* The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In
|
||||
* multi-user mode the three settings routes are admin only: the server receives every session's
|
||||
* events. `discord-notify` follows the session's own ownership rules instead.
|
||||
*/
|
||||
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import { basename, isAbsolute, relative, sep } from 'node:path';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, isAdmin, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
|
||||
import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js';
|
||||
import {
|
||||
discordConfigProblem,
|
||||
MAX_FILE_BYTES,
|
||||
maskToken,
|
||||
readDiscordConfig,
|
||||
writeDiscordConfig,
|
||||
type DiscordBridge,
|
||||
} from '../../discord-bridge.js';
|
||||
import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js';
|
||||
|
||||
export interface DiscordRouteDeps {
|
||||
bridge: DiscordBridge;
|
||||
configDir: string;
|
||||
ctx: SessionPort;
|
||||
hostTitle: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at
|
||||
* least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id.
|
||||
*/
|
||||
function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string {
|
||||
if (ctx.sessions.has(id) || id.length < 8) return id;
|
||||
// Only sessions the caller may see count, so another user's session is never revealed by its prefix.
|
||||
const user = getAuthUser(req);
|
||||
const hits = [...ctx.sessions.values()].filter((s) => s.id.startsWith(id) && canAccessOwned(user, s.owner));
|
||||
return hits.length === 1 ? hits[0].id : id;
|
||||
}
|
||||
|
||||
// A loop or a prompt-injected agent must not ping the phones until Discord answers 429, nor hold 8 MB
|
||||
// uploads in memory without bound. Per session, refilled continuously (the same shape as paste-image's).
|
||||
const NOTIFY_PER_MINUTE = 6;
|
||||
const notifyBuckets = new Map<string, { tokens: number; at: number }>();
|
||||
|
||||
function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolean {
|
||||
if (notifyBuckets.size > 1000) {
|
||||
for (const [k, b] of notifyBuckets) if (now - b.at > 3_600_000) notifyBuckets.delete(k);
|
||||
}
|
||||
const b = notifyBuckets.get(sessionId) ?? { tokens: NOTIFY_PER_MINUTE, at: now };
|
||||
b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + ((now - b.at) * NOTIFY_PER_MINUTE) / 60_000);
|
||||
b.at = now;
|
||||
notifyBuckets.set(sessionId, b);
|
||||
if (b.tokens < 1) return false;
|
||||
b.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Give back a token spent on a request that was refused before anything was posted. */
|
||||
function refundNotifyToken(sessionId: string): void {
|
||||
const b = notifyBuckets.get(sessionId);
|
||||
if (b) b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + 1);
|
||||
}
|
||||
|
||||
/** Test hook: forget every bucket between runs. */
|
||||
export function _resetNotifyBuckets(): void {
|
||||
notifyBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks
|
||||
* followed) lies inside the session's working folder and is not on the attachment blocklist. The
|
||||
* blocklist matters as much as the folder: with a home directory as the session folder, confinement
|
||||
* alone would let a CLI be talked into posting ~/.codeman/discord.json, an ssh key or a `.env`.
|
||||
* Returns the bytes, or the reason.
|
||||
*/
|
||||
async function fileProblem(
|
||||
workingDir: string,
|
||||
remote: boolean,
|
||||
file: string
|
||||
): Promise<{ name: string; data: Uint8Array } | string> {
|
||||
if (remote) return 'Files can only be sent from a local session';
|
||||
if (!isAbsolute(file)) return 'The file path must be absolute';
|
||||
// Bounded first: realpath on a dead mount would hang the request.
|
||||
if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') {
|
||||
return describeUnknownPath('The session folder', workingDir, { pastCap: true });
|
||||
}
|
||||
// The agent chose this path: probe it bounded too, before realpath can hold a worker on a dead mount.
|
||||
const kind = await probePathKind(file, { pastCap: true });
|
||||
if (kind === 'unknown') return describeUnknownPath('The file', file, { pastCap: true });
|
||||
if (kind !== 'file') return 'Not a regular file';
|
||||
try {
|
||||
const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]);
|
||||
const rel = relative(root, real);
|
||||
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
||||
return 'The file must be inside the session folder';
|
||||
if (isBlockedAttachmentPath(real, (await loadAttachmentGuardConfig()).blockedTrees)) {
|
||||
return 'That file cannot be sent: it can hold credentials';
|
||||
}
|
||||
const info = await stat(real);
|
||||
if (!info.isFile()) return 'Not a regular file';
|
||||
if (info.size > MAX_FILE_BYTES) return `The file is over ${MAX_FILE_BYTES / 1024 / 1024} MB`;
|
||||
return { name: basename(real), data: new Uint8Array(await readFile(real)) };
|
||||
} catch {
|
||||
return 'The file could not be read';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void {
|
||||
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const status = async (): Promise<DiscordStatus> => {
|
||||
const { token, ...rest } = await readDiscordConfig(deps.configDir);
|
||||
return {
|
||||
...rest,
|
||||
hasToken: token !== '',
|
||||
tokenMasked: maskToken(token),
|
||||
gateway: deps.bridge.gatewayState,
|
||||
gatewayError: deps.bridge.gatewayError,
|
||||
lastResult: deps.bridge.lastResult,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings');
|
||||
const current = await readDiscordConfig(deps.configDir);
|
||||
const next: DiscordConfig = {
|
||||
...current,
|
||||
...patch,
|
||||
token: patch.token !== undefined ? patch.token.trim() : current.token,
|
||||
};
|
||||
const problem = discordConfigProblem(next);
|
||||
if (problem) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
try {
|
||||
await writeDiscordConfig(deps.configDir, next);
|
||||
await deps.bridge.reload();
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.post('/api/discord/test', async (req, reply): Promise<ApiResponse<DiscordResult>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const cfg = await readDiscordConfig(deps.configDir);
|
||||
if (!cfg.token || !cfg.parentChannelId) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first');
|
||||
}
|
||||
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did.
|
||||
return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) };
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => {
|
||||
const { id: rawId } = req.params as { id: string };
|
||||
const id = resolveSessionId(deps.ctx, req, rawId);
|
||||
const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
|
||||
const session = findSessionOrFail(deps.ctx, id, req);
|
||||
if (!consumeNotifyToken(session.id)) {
|
||||
reply.code(429);
|
||||
reply.header('Retry-After', '60');
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.RATE_LIMITED,
|
||||
`Too many messages (${NOTIFY_PER_MINUTE} per minute per session)`
|
||||
);
|
||||
}
|
||||
let upload: { name: string; data: Uint8Array } | undefined;
|
||||
if (file !== undefined) {
|
||||
const problem = await fileProblem(session.workingDir, !!session.remote, file);
|
||||
if (typeof problem === 'string') {
|
||||
refundNotifyToken(session.id);
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
upload = problem;
|
||||
}
|
||||
const result = await deps.bridge.notify(
|
||||
{
|
||||
event: 'session:discord-notify',
|
||||
title: urgent ? 'Needs you now' : 'Message from the CLI',
|
||||
body: message,
|
||||
urgency: urgent ? 'critical' : 'warning',
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
host: deps.hostTitle(),
|
||||
file: upload,
|
||||
},
|
||||
// deliberate: an agent's own message is never deduped, so a second, different post is not lost.
|
||||
{ urgent: urgent === true, deliberate: true }
|
||||
);
|
||||
if (result === null) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled');
|
||||
}
|
||||
return { success: true, data: result };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview The edit history panel's API: which files a claude session edited (main agent
|
||||
* and subagents), each edit's diff, and Bash-generated images (src/web/edit-history-sync.ts).
|
||||
*
|
||||
* GET /api/sessions/:id/edits[?sync=1] list (no diffs); `sync=1` reads what is new first
|
||||
* GET /api/sessions/:id/edits/:entryId one edit's diff as unified text
|
||||
* POST /api/sessions/:id/edits/older read the next older block, then list
|
||||
*
|
||||
* Read-only, and reads only on these requests (the panel drives it; nothing syncs in the
|
||||
* background). Remote, Docker and non-claude sessions answer `state: 'unsupported'`
|
||||
* without being read. Ownership goes through `findSessionOrFail` with `req`, like every
|
||||
* session-scoped route. No route here serves a file: an image entry is shown through the
|
||||
* existing file routes (workspace `file-raw`, or the attachment routes outside it).
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
import type { EditHistoryEntry, EditHistoryStore } from '../../edit-history-store.js';
|
||||
import type { EditHunk } from '../../edit-history-parse.js';
|
||||
import {
|
||||
unsupportedReason,
|
||||
type EditHistorySession,
|
||||
type EditHistoryState,
|
||||
type EditHistoryStatus,
|
||||
type EditHistorySync,
|
||||
} from '../edit-history-sync.js';
|
||||
|
||||
export interface EditHistoryListEntry extends Omit<EditHistoryEntry, 'hunks'> {
|
||||
relPath: string;
|
||||
}
|
||||
|
||||
export interface EditHistoryList {
|
||||
state: EditHistoryState;
|
||||
reason?: string;
|
||||
backfillTruncated: boolean;
|
||||
agents: Record<string, { description: string; agentType: string; worktreeBranch?: string }>;
|
||||
entries: EditHistoryListEntry[];
|
||||
}
|
||||
|
||||
export interface EditHistoryDiff {
|
||||
diff: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** `path` relative to `base` when it lies below it, else unchanged. */
|
||||
export function relativeTo(path: string, base: string | undefined): string {
|
||||
if (!base) return path;
|
||||
const root = base.endsWith('/') ? base : `${base}/`;
|
||||
return path.startsWith(root) ? path.slice(root.length) : path;
|
||||
}
|
||||
|
||||
/** Hunks as unified diff text, the form the Git panel's renderer reads. */
|
||||
export function hunksToUnified(hunks: readonly EditHunk[]): string {
|
||||
const out: string[] = [];
|
||||
for (const h of hunks) {
|
||||
out.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`);
|
||||
out.push(...h.lines);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function listOf(
|
||||
store: EditHistoryStore,
|
||||
workingDir: string,
|
||||
status: { state: EditHistoryState; reason?: string }
|
||||
): EditHistoryList {
|
||||
const agents = store.getAgents();
|
||||
const entries = store.list().map(({ hunks: _hunks, ...rest }) => ({
|
||||
...rest,
|
||||
relPath: relativeTo(rest.path, (rest.agentId && agents[rest.agentId]?.worktreePath) || workingDir),
|
||||
}));
|
||||
const publicAgents: EditHistoryList['agents'] = {};
|
||||
for (const [id, a] of Object.entries(agents)) {
|
||||
publicAgents[id] = { description: a.description, agentType: a.agentType };
|
||||
if (a.worktreeBranch) publicAgents[id].worktreeBranch = a.worktreeBranch;
|
||||
}
|
||||
const list: EditHistoryList = {
|
||||
state: status.state,
|
||||
backfillTruncated: store.backfillTruncated,
|
||||
agents: publicAgents,
|
||||
entries,
|
||||
};
|
||||
if (status.reason) list.reason = status.reason;
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
|
||||
function unsupportedList(reason: string): EditHistoryList {
|
||||
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
|
||||
}
|
||||
|
||||
export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort, sync: EditHistorySync): void {
|
||||
const sessionOf = (id: string, req: Parameters<typeof findSessionOrFail>[2]) => {
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const view: EditHistorySession = {
|
||||
id: session.id,
|
||||
mode: session.mode,
|
||||
remote: session.remote,
|
||||
docker: session.docker,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
claudeConfigDir: session.claudeConfigDir(),
|
||||
};
|
||||
return { session, view };
|
||||
};
|
||||
|
||||
app.get('/api/sessions/:id/edits', async (req): Promise<ApiResponse<EditHistoryList>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { sync: doSync } = req.query as { sync?: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const why = unsupportedReason(view);
|
||||
// Never read: answer an empty list without loading (or creating) a store for it.
|
||||
if (why) return { success: true, data: unsupportedList(why) };
|
||||
// The sync starts in the same tick as the session check: a deletion right after it marks
|
||||
// the running sync gone (EditHistorySync.remove), so nothing writes a deleted session.
|
||||
const status: EditHistoryStatus = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id);
|
||||
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/edits/older', async (req): Promise<ApiResponse<EditHistoryList>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const why = unsupportedReason(view);
|
||||
if (why) return { success: true, data: unsupportedList(why) };
|
||||
const status = await sync.sync(view, { older: true });
|
||||
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/edits/:entryId', async (req, reply): Promise<ApiResponse<EditHistoryDiff>> => {
|
||||
const { id, entryId } = req.params as { id: string; entryId: string };
|
||||
const { session, view } = sessionOf(id, req);
|
||||
const entry = unsupportedReason(view) ? undefined : (await sync.getStore(session.id)).get(entryId);
|
||||
if (!entry) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such edit in this session');
|
||||
}
|
||||
return { success: true, data: { diff: hunksToUnified(entry.hunks), truncated: entry.truncated } };
|
||||
});
|
||||
}
|
||||
+341
-36
@@ -23,6 +23,7 @@ import type {
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { compileFileQuery } from '../../utils/file-query.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
AUDIO_ATTACHMENT_EXTENSIONS,
|
||||
@@ -30,12 +31,21 @@ import {
|
||||
attachmentRecordToEvent,
|
||||
attachmentRegistry,
|
||||
buildFileThumbnailRoute,
|
||||
getAttachmentType,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
TEXT_ATTACHMENT_EXTENSIONS,
|
||||
VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import {
|
||||
FolderListingError,
|
||||
folderFilePath,
|
||||
folderGrants,
|
||||
folderAllowedForUser,
|
||||
listFolder,
|
||||
validFolderFileName,
|
||||
} from '../../folder-listing.js';
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
@@ -47,6 +57,7 @@ import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validateSessionFilePath,
|
||||
validateSessionFilePathLexical,
|
||||
} from '../route-helpers.js';
|
||||
@@ -64,7 +75,12 @@ import { parseByteRange } from '../http-range.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js';
|
||||
import {
|
||||
FilesystemBrowseQuerySchema,
|
||||
FilesystemMkdirSchema,
|
||||
FilesystemPreviewQuerySchema,
|
||||
FileWriteSchema,
|
||||
} from '../schemas.js';
|
||||
import {
|
||||
MAX_EDITABLE_BYTES,
|
||||
applyEol,
|
||||
@@ -200,8 +216,8 @@ interface FileBodySource {
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
/** Byte source for a LOCAL file (the historical, only path). */
|
||||
function localFileSource(resolvedPath: string) {
|
||||
/** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
|
||||
export function localFileSource(resolvedPath: string) {
|
||||
return (range?: { start: number; end: number }): FileBodySource => ({
|
||||
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
|
||||
});
|
||||
@@ -221,8 +237,11 @@ function localFileSource(resolvedPath: string) {
|
||||
* `open` supplies the bytes for the (optional) range, which is what lets a remote
|
||||
* case reuse this instead of re-implementing the 206/416 contract: same headers,
|
||||
* same status codes, whether the file sits on this host or behind an ssh pipe.
|
||||
*
|
||||
* Exported for the rendered-HTML route (html-view-routes.ts), so a page's own
|
||||
* `<video>`/`<audio>` can seek as well.
|
||||
*/
|
||||
function sendFileBody(
|
||||
export function sendFileBody(
|
||||
reply: FastifyReply,
|
||||
size: number,
|
||||
rangeHeader: string | string[] | undefined,
|
||||
@@ -271,7 +290,9 @@ async function serveRawFile(
|
||||
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
|
||||
// family is servable, so widening what can be READ never widened what can RUN.
|
||||
// The preview overlay reads these through `fetch()`, which ignores the
|
||||
// disposition, so a clicked .html still shows its source.
|
||||
// disposition, so the source view of a clicked .html still works. Rendering it
|
||||
// as a page is html-view-routes.ts's job (sandboxed, opaque origin), never this
|
||||
// route's.
|
||||
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
|
||||
if (download || markupOnly) {
|
||||
reply.header(
|
||||
@@ -426,6 +447,40 @@ async function resolveServableRemoteAttachment(
|
||||
return { path: probe.realPath, probe };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
|
||||
* the attachment raw route, shared with the folder-grid file route.
|
||||
*
|
||||
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
|
||||
* contract, and its size comes from the guard's own re-probe — so serving a remote
|
||||
* file needs no stat the local branch would not also need.
|
||||
*/
|
||||
async function serveServableRaw(
|
||||
reply: FastifyReply,
|
||||
servable: ServableAttachment,
|
||||
remote: SessionRemote | undefined,
|
||||
fileName: string,
|
||||
extension: string,
|
||||
query: { download?: string; preview?: string; range?: string }
|
||||
): Promise<void> {
|
||||
try {
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(extension, query, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
||||
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
||||
@@ -520,8 +575,20 @@ function getKnownSessionWorkingDir(
|
||||
interface SessionFileScope {
|
||||
workingDir: string;
|
||||
remote?: SessionRemote;
|
||||
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
|
||||
docker?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Docker case is a LOCAL session for the file routes (the workspace is a
|
||||
* bind mount, so its paths are host paths), but `~` inside the container is the
|
||||
* container's own seeded home, not this host's. Expanding `~/x.png` against the
|
||||
* host's home would open a different file than the one the agent printed, so
|
||||
* the form is refused instead of guessed.
|
||||
*/
|
||||
export const DOCKER_HOME_PATH_ERROR =
|
||||
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
|
||||
|
||||
/**
|
||||
* The file-access scope of a session, live or persisted — the `workingDir`-only
|
||||
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
|
||||
@@ -539,12 +606,12 @@ function getKnownSessionFileScope(
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) {
|
||||
return { workingDir: liveSession.workingDir, remote: liveSession.remote };
|
||||
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) {
|
||||
return { workingDir: stored.workingDir, remote: stored.remote };
|
||||
return { workingDir: stored.workingDir, remote: stored.remote, docker: !!stored.docker };
|
||||
}
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
@@ -706,7 +773,11 @@ const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif'
|
||||
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
|
||||
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
|
||||
|
||||
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
/**
|
||||
* Whether `candidate` is `root` itself or lies beneath it (both already resolved). The one lexical
|
||||
* containment check for workspace confinement; ralph-routes.ts uses it for a remote `@fix_plan.md`.
|
||||
*/
|
||||
export function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||
}
|
||||
@@ -1383,6 +1454,83 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
});
|
||||
|
||||
// "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse.
|
||||
app.post('/api/filesystem/mkdir', async (req, reply): Promise<ApiResponse<{ path: string }>> => {
|
||||
const { path: parentPath, name, sessionId, showHidden } = parseBody(FilesystemMkdirSchema, req.body);
|
||||
// Probed first (#516): the picker's resolver runs realpathSync on this path, which would freeze the
|
||||
// whole server on a mount that stopped answering.
|
||||
if ((await probePathKind(parentPath, { pastCap: true })) === 'unknown') {
|
||||
reply.code(422);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
describeUnknownPath('The folder', parentPath, { pastCap: true })
|
||||
);
|
||||
}
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
parentPath,
|
||||
sessionId,
|
||||
wantsHiddenPickerEntries(showHidden)
|
||||
);
|
||||
const target = join(resolvedPath, name);
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees, true) || isBlockedPickerPath(target, blockedTrees, true)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here');
|
||||
}
|
||||
// The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as
|
||||
// local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which
|
||||
// deleting would take the linked folder with. A case there is made through Add Case > New Case.
|
||||
const casesDirs = await Promise.all(
|
||||
[...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) =>
|
||||
fs.realpath(dir).catch(() => resolve(dir))
|
||||
)
|
||||
);
|
||||
if (casesDirs.includes(resolvedPath)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.'
|
||||
);
|
||||
}
|
||||
// Like browse, a file is not a place to create in.
|
||||
try {
|
||||
if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory');
|
||||
} catch {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The folder to create in must be a directory');
|
||||
}
|
||||
try {
|
||||
// Not recursive: the parent was just resolved inside an allowed root, and an existing folder must be a 409.
|
||||
await fs.mkdir(target);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EEXIST') {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `"${name}" already exists in this folder`);
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable');
|
||||
}
|
||||
if (code === 'ENOENT') {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
if (code === 'ENAMETOOLONG') {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long');
|
||||
}
|
||||
// Never the errno text: it names the resolved, symlink-followed path, not the one the picker shows.
|
||||
reply.code(500);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Could not create the folder${code ? ` (${code})` : ''}`
|
||||
);
|
||||
}
|
||||
return { success: true, data: { path: join(candidatePath, name) } };
|
||||
});
|
||||
|
||||
// Inline preview for files selected through the root-confined filesystem picker.
|
||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
@@ -2165,6 +2313,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
|
||||
return;
|
||||
}
|
||||
if (session.docker && body.path.startsWith('~/')) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// A remote case registers a path that lives on the REMOTE host: the guard and
|
||||
@@ -2285,34 +2437,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
});
|
||||
if (!servable) return;
|
||||
|
||||
try {
|
||||
// A remote record streams over ssh exactly like file-raw, with the same
|
||||
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
|
||||
// serving a remote attachment needs no stat the local branch would not also need.
|
||||
const remote = session.remote;
|
||||
const target: FileTarget =
|
||||
servable.probe && remote
|
||||
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
||||
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
||||
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
||||
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
|
||||
sendXlsxPreviewTooLarge(reply, size);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(
|
||||
reply,
|
||||
target,
|
||||
size,
|
||||
record.fileName,
|
||||
record.extension,
|
||||
download === 'true',
|
||||
req.headers.range
|
||||
);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
||||
}
|
||||
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
||||
@@ -2376,6 +2505,182 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
await serveThumbnail(reply, servable.path, record.extension);
|
||||
});
|
||||
|
||||
// ===== Folder grid =====
|
||||
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
|
||||
// grid. Same guard as a single external attachment; the grid then fetches files by
|
||||
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
|
||||
app.post('/api/sessions/:id/folders', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return;
|
||||
const body = (req.body || {}) as { path?: unknown };
|
||||
if (!body.path || typeof body.path !== 'string') {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
|
||||
return;
|
||||
}
|
||||
// A bare `~` expands to this host's home too, not the container's.
|
||||
if (scope.docker && (body.path === '~' || body.path.startsWith('~/'))) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const listing = await listFolder(id, body.path, {
|
||||
sessionWorkingDir: scope.workingDir,
|
||||
remote: scope.remote,
|
||||
user: getAuthUser(req),
|
||||
});
|
||||
return { success: true, data: listing };
|
||||
} catch (err) {
|
||||
if (err instanceof FolderListingError) {
|
||||
const code =
|
||||
err.statusCode === 404
|
||||
? ApiErrorCode.NOT_FOUND
|
||||
: err.statusCode >= 500
|
||||
? ApiErrorCode.OPERATION_FAILED
|
||||
: ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
|
||||
return;
|
||||
}
|
||||
return reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* One file of a granted folder, guard-checked like an attachment: the name is a
|
||||
* bare file name of a supported type, and the joined path goes through the same
|
||||
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
|
||||
* re-resolution, remote re-probe) an attachment by id does. The multi-user rule
|
||||
* ({@link folderAllowedForUser}) runs again on the resolved file: a symlink inside
|
||||
* the granted folder must not lead into another user's space.
|
||||
*/
|
||||
async function resolveFolderFile(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
|
||||
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
|
||||
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
||||
if (!scope) return null;
|
||||
const grant = folderGrants.get(id, folderId);
|
||||
if (!grant) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
|
||||
return null;
|
||||
}
|
||||
const fileName = validFolderFileName(name);
|
||||
if (!fileName) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
|
||||
return null;
|
||||
}
|
||||
const filePath = folderFilePath(grant.dir, fileName);
|
||||
if (!scope.remote) {
|
||||
// Bounded, like the folder itself: the grant may point at a mount that has stopped answering since.
|
||||
const kind = await probePathKind(filePath, { pastCap: true });
|
||||
if (kind === 'unknown') {
|
||||
reply
|
||||
.code(503)
|
||||
.send(
|
||||
createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('File', filePath, { pastCap: true }))
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (kind !== 'file') {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
|
||||
const record: AttachmentRecord = {
|
||||
attachmentId: '',
|
||||
sessionId: id,
|
||||
filePath,
|
||||
fileName,
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
timestamp: 0,
|
||||
source: 'external',
|
||||
};
|
||||
const servable = await resolveServableAttachmentPath(reply, record, scope);
|
||||
if (!servable) return null;
|
||||
// The requested NAME only opens the door; registration judges the file a
|
||||
// symlink resolves to (supported extension, regular file) and so does this.
|
||||
// Without it `shot.png -> ../secret.bin` is served, and a FIFO or a device
|
||||
// behind a supported name parks a worker on an open that never returns.
|
||||
if (!isSupportedAttachmentExtension(extname(servable.path))) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Unsupported attachment type'));
|
||||
return null;
|
||||
}
|
||||
const isRegularFile = scope.remote
|
||||
? servable.probe?.kind === 'file'
|
||||
: await fs.stat(servable.path).then(
|
||||
(stat) => stat.isFile(),
|
||||
() => false
|
||||
);
|
||||
if (!isRegularFile) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Attachment path is not a file'));
|
||||
return null;
|
||||
}
|
||||
if (!folderAllowedForUser(servable.path, getAuthUser(req), scope)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File is outside your space'));
|
||||
return null;
|
||||
}
|
||||
return { scope, servable, name: fileName, extension };
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
const { download, preview } = req.query as { download?: string; preview?: string };
|
||||
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
|
||||
download,
|
||||
preview,
|
||||
range: req.headers.range,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.extension !== 'docx' && file.extension !== 'pptx') {
|
||||
const { id, folderId } = req.params as { id: string; folderId: string };
|
||||
reply.redirect(
|
||||
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Office document preview is not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
|
||||
const file = await resolveFolderFile(req, reply);
|
||||
if (!file) return;
|
||||
if (file.servable.probe) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Thumbnails are not available for files in a remote (SSH) case'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await serveThumbnail(reply, file.servable.path, file.extension);
|
||||
});
|
||||
|
||||
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
||||
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
|
||||
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
|
||||
* @fileoverview The Git panel's routes: `GET /api/sessions/:id/git-status` (what the session's workspace
|
||||
* has not committed or pushed, src/git-workspace-status.ts) and `POST /api/sessions/:id/git-fetch` (the
|
||||
* panel's Fetch button, src/git-fetch.ts), for the bottom-bar Git indicator and its window. The answer is
|
||||
* an overview: the enclosing repository, or each repository found below a folder that holds several
|
||||
* projects (see `getGitWorkspaceOverview` for exactly which).
|
||||
*
|
||||
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
|
||||
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
|
||||
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
|
||||
* Read-only and offline: the status route never fetches and never runs a git write command. Fetching is a
|
||||
* separate, user-triggered path (`POST /api/sessions/:id/git-fetch`, src/git-fetch.ts) that the panel calls
|
||||
* only when the user clicks Fetch. A remote (SSH) or Docker session is not inspected and answers
|
||||
* `state: 'unsupported'` on both routes, and neither is any repository at or inside a Docker case
|
||||
* workspace (a container can write there, and git here would run on the host). Ownership goes through
|
||||
* `findSessionOrFail`, like every session-scoped route.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { redactGitCredentials } from '../../git-clone.js';
|
||||
import { readDockerCases } from '../../docker-hosts.js';
|
||||
import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js';
|
||||
import { readDockerCasesOrThrow } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js';
|
||||
import {
|
||||
emptyOverview,
|
||||
findWorkspaceRepo,
|
||||
@@ -28,11 +32,24 @@ import {
|
||||
type GitRunner,
|
||||
type GitWorkspaceOverview,
|
||||
} from '../../git-workspace-status.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||
|
||||
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
|
||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||
/**
|
||||
* Host paths of every Docker case workspace: repositories at or inside these are never inspected.
|
||||
* Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list: the Docker rule has
|
||||
* to be one rule, checked by every path that could run git.
|
||||
*
|
||||
* REJECTS when `docker-cases.json` exists but cannot be read or parsed (its writer truncates and rewrites it
|
||||
* in place, so a read can land mid-write). The fetch paths fail closed on that: a fetch runs a repository's
|
||||
* own hooks, and without the list nothing tells which folders a container can write to. The read-only
|
||||
* status and diff routes read it as no Docker workspaces, as they always have.
|
||||
*/
|
||||
export const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
(await readDockerCasesOrThrow(getDataDir())).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||
|
||||
/** The read-only routes' reading of the list: unchanged, an unreadable list is no Docker workspaces. */
|
||||
const dockerWorkspacesOrNone = (dockerWorkspaces: () => Promise<string[]>): Promise<string[]> =>
|
||||
dockerWorkspaces().catch(() => []);
|
||||
|
||||
/**
|
||||
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
|
||||
@@ -50,7 +67,9 @@ function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) {
|
||||
|
||||
export function registerGitStatusRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort,
|
||||
// EventPort as well as SessionPort: the fetch route publishes the run it did, and a route that broadcasts
|
||||
// has to say so in its own signature.
|
||||
ctx: SessionPort & EventPort,
|
||||
git?: GitRunner,
|
||||
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
|
||||
): void {
|
||||
@@ -68,7 +87,7 @@ export function registerGitStatusRoutes(
|
||||
git,
|
||||
fresh: fresh === '1',
|
||||
...limits,
|
||||
dockerWorkspaces: await dockerWorkspaces(),
|
||||
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -92,7 +111,7 @@ export function registerGitStatusRoutes(
|
||||
? await findWorkspaceRepo(session.workingDir, repo, {
|
||||
git,
|
||||
...limits,
|
||||
dockerWorkspaces: await dockerWorkspaces(),
|
||||
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
|
||||
})
|
||||
: null;
|
||||
const status = repoRoot
|
||||
@@ -119,4 +138,54 @@ export function registerGitStatusRoutes(
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one
|
||||
// `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot
|
||||
// disagree about which repositories belong to the workspace. This is the only git command the Git panel runs
|
||||
// that writes, and it writes remote-tracking refs only: nothing commits, checks out or merges. Cloning and
|
||||
// worktree management write too, but those happen when the user asked for them; this route is the one that
|
||||
// can also run later, on a timer. It is not as harmless as a read though: a fetch holds git's ref lock, so
|
||||
// an agent's own `git pull` in the same clone can collide with it, which is why the run goes through
|
||||
// `fetchWorkspaceOnce`: a click while a tick is already fetching this folder joins that run instead of
|
||||
// starting a second one. It can also run a
|
||||
// repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a
|
||||
// fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn
|
||||
// maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote
|
||||
// and Docker sessions answer `unsupported` like the status route.
|
||||
app.post('/api/sessions/:id/git-fetch', async (req): Promise<ApiResponse<GitFetchReport>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { maxRepos?: unknown; timeout?: unknown };
|
||||
const limits = limitsFrom(query);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
|
||||
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
|
||||
// Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never
|
||||
// runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`),
|
||||
// published like one, so the Session Options Git row shows it.
|
||||
let dockerRoots: string[];
|
||||
try {
|
||||
dockerRoots = await dockerWorkspaces();
|
||||
} catch (err) {
|
||||
console.warn(`[git fetch] Docker case list unreadable, nothing fetched: ${getErrorMessage(err)}`);
|
||||
const report = emptyReport('error', {
|
||||
error:
|
||||
'Nothing was fetched: Codeman could not read its Docker case list (docker-cases.json), so it cannot tell which folders a container can write to.',
|
||||
});
|
||||
if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, data: report };
|
||||
}
|
||||
const run = await fetchWorkspaceOnce(
|
||||
session.workingDir,
|
||||
{ git, ...limits, dockerWorkspaces: dockerRoots },
|
||||
'click'
|
||||
);
|
||||
// The response answers the click, and the run is also the session's current truth: what the panel's
|
||||
// "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a
|
||||
// second tab open on the same folder see the same run. The trigger recorded is the RUN's, not this
|
||||
// request's: a click that joined a tick already going fetched nothing itself, so the row has to name the
|
||||
// interval as what fetched.
|
||||
if (session.setGitFetchStatus(gitFetchStatusFrom(run.report, run.trigger)))
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { success: true, data: run.report };
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -142,7 +143,8 @@ export function registerHookEventRoutes(
|
||||
// bumped only by input that flowed through Codeman's own write path, so
|
||||
// it read 0 forever for those panes and every consumer of "when did this
|
||||
// pane last submit" silently degraded.
|
||||
session?.markPromptSubmitted();
|
||||
// The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts).
|
||||
session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined);
|
||||
}
|
||||
// Persist when the conversation actually moved: `/clear` emits no
|
||||
// completion event, so without this the successor id is lost on restart
|
||||
@@ -182,9 +184,21 @@ export function registerHookEventRoutes(
|
||||
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
|
||||
// is worth even less on a phone than in a tab.
|
||||
let acknowledgedReason: string | undefined;
|
||||
// A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts)
|
||||
// ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged,
|
||||
// so no tab, toast or push asks a person to look at a session that wants nothing from them.
|
||||
const keepAliveReason =
|
||||
session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined;
|
||||
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||
// Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly
|
||||
// as it found it: its stop does not resolve it, and its idle prompt does not replace it (a
|
||||
// replacement would forget the item was unseen, or that a person had seen it). With no idle
|
||||
// item pending, the keep-alive's idle prompt opens a pre-acknowledged one.
|
||||
const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle';
|
||||
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
if (approvalKind) {
|
||||
if (approvalKind === 'idle' && keepsPendingIdle) {
|
||||
// Left in place: no new item, and the broadcast below carries the keep-alive reason.
|
||||
} else if (approvalKind) {
|
||||
const toolInput =
|
||||
safeData.tool_input && typeof safeData.tool_input === 'object'
|
||||
? (safeData.tool_input as Record<string, unknown>)
|
||||
@@ -200,10 +214,15 @@ export function registerHookEventRoutes(
|
||||
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
|
||||
message: typeof safeData.message === 'string' ? safeData.message : undefined,
|
||||
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
|
||||
// Supervised-hook CLIs (DeepSeek Harness): dialog keystrokes are unverifiable,
|
||||
// so the item is an alert — answer in the terminal. Capability-gated, not by
|
||||
// mode id, so a future supervised CLI inherits the same shape.
|
||||
answerInTerminal: getCli(session.mode)?.capabilities.hooks === 'supervised',
|
||||
// What the pane says is still running in the background. An idle prompt from a
|
||||
// session that is watching its own work opens acknowledged, so it never arms an
|
||||
// alert nobody can answer; notePrompt() carries the whole reasoning.
|
||||
watching: session.watching,
|
||||
preAcknowledged: keepAliveReason,
|
||||
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
|
||||
// the fallback for direct-PTY sessions and the no-op test mux.
|
||||
capture: () => {
|
||||
@@ -214,11 +233,16 @@ export function registerHookEventRoutes(
|
||||
});
|
||||
approvalId = item.id;
|
||||
acknowledgedReason = item.acknowledgedReason;
|
||||
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
|
||||
} else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) {
|
||||
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgedReason ??= keepAliveReason;
|
||||
// The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here
|
||||
// rather than on a fixed clock that a slow turn could outlast.
|
||||
if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn();
|
||||
|
||||
ctx.broadcast(`hook:${event}`, {
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
@@ -244,7 +268,9 @@ export function registerHookEventRoutes(
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
// Supervised-hook CLIs answer in the terminal: omit approvalId so push
|
||||
// cannot offer Approve/Deny that the answer route would refuse.
|
||||
...(approvalId && session && getCli(session.mode)?.capabilities.hooks !== 'supervised' && { approvalId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,9 +278,11 @@ export function registerHookEventRoutes(
|
||||
// Claude pane; only the ones where the conversation actually moved (a /clear
|
||||
// successor) carry information, and recording the rest would push a row into
|
||||
// the Summary timeline and /api/search per turn and evict useful rows from
|
||||
// the 1000-event FIFO (#367 merge-time fix).
|
||||
// the 1000-event FIFO (#367 merge-time fix). A keep-alive turn's stop and idle prompt are
|
||||
// left out too: the summary files a `stop` as a warning, which the Away digest lists under
|
||||
// Needs Attention, and that turn is Codeman's own, not the agent's.
|
||||
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
|
||||
if (summaryTracker && !keepAliveReason && (event !== 'prompt_submitted' || conversationChanged)) {
|
||||
summaryTracker.recordHookEvent(event, safeData);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @fileoverview Render a local HTML file (an agent's report, a generated page) as a
|
||||
* page instead of as source text.
|
||||
*
|
||||
* Routes:
|
||||
* - `POST /api/sessions/:id/html-view` (authenticated) — `{ path }` of an `.html` file;
|
||||
* returns `{ url }`, an `/html-view/<cap>/<file>` URL for that file.
|
||||
* - `GET /html-view/:cap/*` (capability) — the HTML file and its sibling assets.
|
||||
*
|
||||
* ⚠️ Every response carries `Content-Security-Policy: sandbox ...` WITHOUT
|
||||
* `allow-same-origin`, so the page runs in an opaque origin whether it is framed or
|
||||
* opened as a top-level tab: it can run its own scripts but cannot read the Codeman
|
||||
* document, its cookies or its API. That is what makes rendering safe at all; see
|
||||
* html-view-capabilities.ts for why the route authenticates on a capability.
|
||||
* Network access is deliberately OPEN (owner decision, see {@link HTML_VIEW_CSP}).
|
||||
*
|
||||
* Path policy: the HTML file itself is admitted like the file preview admits a file
|
||||
* (inside the session workspace, or outside it under the attachment guard), and the
|
||||
* capability then serves ONLY the files in that file's own directory tree, filtered
|
||||
* by the same blocklist plus an asset-extension allowlist and a no-dotfile rule, so
|
||||
* a page cannot use its own directory to pull a `.env` out through the browser.
|
||||
* Because that tree is served recursively, minting is refused for an HTML file in a
|
||||
* hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and
|
||||
* `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a
|
||||
* directory above one, see {@link isBroadHtmlViewRoot}.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { HtmlViewOpenSchema } from '../schemas.js';
|
||||
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
|
||||
import { getUserSpacesDir } from '../../config/multiuser.js';
|
||||
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/** Extensions that open as a rendered page. */
|
||||
export const HTML_VIEW_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm']);
|
||||
|
||||
/** What a rendered page may load from its own directory, with the type it is served as. */
|
||||
const ASSET_TYPES: Record<string, string> = {
|
||||
html: 'text/html; charset=utf-8',
|
||||
htm: 'text/html; charset=utf-8',
|
||||
css: 'text/css; charset=utf-8',
|
||||
js: 'text/javascript; charset=utf-8',
|
||||
mjs: 'text/javascript; charset=utf-8',
|
||||
json: 'application/json; charset=utf-8',
|
||||
map: 'application/json; charset=utf-8',
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
csv: 'text/csv; charset=utf-8',
|
||||
md: 'text/plain; charset=utf-8',
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
otf: 'font/otf',
|
||||
wasm: 'application/wasm',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole Content-Security-Policy every response is served under: the `sandbox`
|
||||
* directive and nothing else. It REPLACES Codeman's own policy (`default-src 'self'`
|
||||
* and the rest, set earlier by registerSecurityHeaders). No `allow-same-origin`:
|
||||
* that one token is the difference between "a page" and "a page that can drive
|
||||
* Codeman".
|
||||
*
|
||||
* ⚠️ Network access is OPEN, by the owner's decision (2026-10-13, #547): a rendered
|
||||
* page behaves like a file opened from disk in a browser, so there is deliberately
|
||||
* no fetch directive (`default-src`, `script-src`, `connect-src`, ...). It loads
|
||||
* scripts, styles, fonts, images and media from anywhere (an agent's dashboard
|
||||
* pulling Chart.js from a CDN) and may fetch, XHR, open WebSockets, send beacons and
|
||||
* post forms anywhere. What confines it is the opaque origin (no Codeman cookies, no
|
||||
* Codeman API) and the capability serving only its own folder. Do not add a fetch
|
||||
* directive, or `allow-same-origin`, without the owner. test/routes/html-view-routes.test.ts
|
||||
* pins the exact value.
|
||||
*/
|
||||
export const HTML_VIEW_CSP = 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads';
|
||||
|
||||
function extensionOf(path: string): string {
|
||||
return extname(path).toLowerCase().replace(/^\./, '');
|
||||
}
|
||||
|
||||
/** realpath when the path exists, else the plain resolved path. */
|
||||
function canonical(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared temp dirs, named literally: `os.tmpdir()` is `$TMPDIR` whenever that
|
||||
* is set, and macOS sets it in every terminal session (`/var/folders/.../T`), so
|
||||
* a server started from one would otherwise never see `/tmp` as broad.
|
||||
*/
|
||||
const SHARED_TEMP_DIRS = ['/tmp', '/var/tmp'];
|
||||
|
||||
/**
|
||||
* Where drives are mounted. A page at a drive's root (WSL's `/mnt/d/report.html`)
|
||||
* would be handed the whole drive, so the mount parent and each mount point in it
|
||||
* count as broad.
|
||||
*/
|
||||
const MOUNT_PARENTS = ['/mnt', '/media', '/Volumes'];
|
||||
|
||||
/**
|
||||
* Whether a directory is too broad to hand to a page as its asset root. The
|
||||
* capability serves the HTML file's directory RECURSIVELY to the page's own
|
||||
* scripts, so `/tmp/report.html` would expose every other session's scratch
|
||||
* output under `/tmp`, and `~/report.html` the home tree. Refuse those roots
|
||||
* (and the cases / user-space roots, which hold every workspace) outright, and
|
||||
* any directory ABOVE one of them (`/home`, `/Users`, macOS's `/private`), since
|
||||
* serving it recursively serves the root below it too.
|
||||
*/
|
||||
export function isBroadHtmlViewRoot(dir: string): boolean {
|
||||
const target = canonical(dir);
|
||||
const userSpaces = canonical(getUserSpacesDir());
|
||||
// canonical() also maps the literal temp dirs to macOS's `/private/tmp` and `/private/var/tmp`.
|
||||
const broad = [sep, homedir(), tmpdir(), ...SHARED_TEMP_DIRS, CASES_DIR, userSpaces].map(canonical);
|
||||
if (broad.some((root) => isUnderTree(root, target))) return true;
|
||||
for (const parent of MOUNT_PARENTS.map(canonical)) {
|
||||
if (target === parent || dirname(target) === parent) return true;
|
||||
}
|
||||
// A user's own space (`<spaces>/<user>`) and its cases dir hold every workspace of that user.
|
||||
if (dirname(target) === userSpaces) return true;
|
||||
return basename(target) === 'cases' && dirname(dirname(target)) === userSpaces;
|
||||
}
|
||||
|
||||
export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
app.post('/api/sessions/:id/html-view', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const body = parseBody(HtmlViewOpenSchema, req.body ?? {});
|
||||
if (session.remote) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML pages in a remote (SSH) case cannot be rendered'));
|
||||
return;
|
||||
}
|
||||
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(body.path))) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
|
||||
return;
|
||||
}
|
||||
// A `~/` link (the terminal passes a home-relative path through, since only
|
||||
// the server knows the home) is this host's home, as the attachment route
|
||||
// expands it. A Docker case's `~` is the container's, so it is refused there.
|
||||
let requestedPath = body.path;
|
||||
if (requestedPath.startsWith('~/')) {
|
||||
if (session.docker) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
|
||||
return;
|
||||
}
|
||||
requestedPath = join(homedir(), requestedPath.slice(2));
|
||||
}
|
||||
|
||||
// Inside the workspace: the same check every file route uses. Outside it: the
|
||||
// attachment guard, exactly as an external file preview is admitted.
|
||||
let resolvedPath: string;
|
||||
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
|
||||
if (inWorkspace) {
|
||||
resolvedPath = inWorkspace.resolvedPath;
|
||||
} else {
|
||||
if (!isAbsolute(requestedPath)) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolvedPath = await fs.realpath(requestedPath);
|
||||
} catch {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await fs.stat(resolvedPath).catch(() => null);
|
||||
if (!stat?.isFile()) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
// The page URL names the RESOLVED file, so it has to be HTML too: a
|
||||
// `report.html -> notes.txt` link would otherwise mint a page for a text file.
|
||||
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(resolvedPath))) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
|
||||
return;
|
||||
}
|
||||
|
||||
// The serving route never descends into a dot segment below the capability
|
||||
// root; the HTML file's own path gets the same rule, or `.cache/r.html` would
|
||||
// mint a capability over a hidden directory and serve its `token.json`. Inside
|
||||
// the workspace the rule covers the part below the workspace root, which is
|
||||
// all the page's tree can reach, so a workspace that itself lives under a
|
||||
// hidden folder (`~/.local/src/app`) can still render its pages.
|
||||
const checkedPath = inWorkspace ? inWorkspace.relativePath : resolvedPath;
|
||||
if (checkedPath.split(sep).some((segment) => segment.startsWith('.'))) {
|
||||
reply
|
||||
.code(403)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML files in hidden directories cannot be rendered'));
|
||||
return;
|
||||
}
|
||||
if (isBroadHtmlViewRoot(dirname(resolvedPath))) {
|
||||
reply
|
||||
.code(403)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'This folder is too broad to render a page from; move the HTML file into its own folder'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
|
||||
return {
|
||||
success: true,
|
||||
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
|
||||
};
|
||||
});
|
||||
|
||||
app.get(`${HTML_VIEW_PREFIX}/:cap/*`, async (req, reply) => {
|
||||
const { cap, '*': rest } = req.params as { cap: string; '*': string };
|
||||
const record = htmlViewCapabilities.resolve(cap);
|
||||
const notFound = () => reply.code(404).header('Content-Type', 'text/plain; charset=utf-8').send('Not found');
|
||||
if (!record) return notFound();
|
||||
|
||||
// Fastify has already percent-decoded the wildcard. Hidden files and
|
||||
// directories are never served, whatever their extension. This is the cheap
|
||||
// reject on the requested name; the same two rules run again on the resolved
|
||||
// path below.
|
||||
const segments = (rest || '').split('/').filter(Boolean);
|
||||
if (segments.length === 0 || segments.some((s) => s.startsWith('.'))) return notFound();
|
||||
if (!ASSET_TYPES[extensionOf(segments[segments.length - 1])]) return notFound();
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await fs.realpath(resolve(record.rootDir, segments.join(sep)));
|
||||
} catch {
|
||||
return notFound();
|
||||
}
|
||||
// Symlinks are resolved above, so this is the real containment check, and the
|
||||
// dot and extension rules have to hold for where a link LANDS as well: a
|
||||
// `cfg.json -> .git/config` inside the page's folder passes both on its own name.
|
||||
if (!isUnderTree(resolvedPath, record.rootDir)) return notFound();
|
||||
const landed = relative(record.rootDir, resolvedPath).split(sep);
|
||||
if (landed.some((s) => s.startsWith('.'))) return notFound();
|
||||
// Typed by what the bytes are, the resolved name.
|
||||
const contentType = ASSET_TYPES[extensionOf(resolvedPath)];
|
||||
if (!contentType) return notFound();
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) return notFound();
|
||||
// Re-checked per request, like the attachment serve path (file-routes.ts): a
|
||||
// page minted from outside the workspace before an admin switched confinement
|
||||
// on stops serving once it is on, instead of living out its capability.
|
||||
if (record.outsideWorkspace && guard.confineToWorkspace) return notFound();
|
||||
|
||||
const stat = await fs.stat(resolvedPath).catch(() => null);
|
||||
if (!stat?.isFile() || exceedsDownloadLimit(stat.size)) return notFound();
|
||||
|
||||
reply.header('Content-Security-Policy', HTML_VIEW_CSP);
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.header('Referrer-Policy', 'no-referrer');
|
||||
// The sandboxed page is opaque-origin, so even its fetch() of a sibling
|
||||
// `data.json` is CORS-checked with `Origin: null`. `*` without credentials is
|
||||
// enough, and widens nothing: these bytes are already readable by anyone
|
||||
// holding the capability URL.
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
// The file routes' body sender: Content-Length, and `Range` answered with 206
|
||||
// (or 416), so a page's own <video>/<audio> can seek. It hijacks the reply, and
|
||||
// carries every header set above across to it.
|
||||
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @fileoverview Agent inbox routes: post, read (optionally long-polling), ack, and an
|
||||
* owner-scoped summary of counts. Store semantics live in `agent-inbox.ts`.
|
||||
*
|
||||
* Who is calling decides what a read or an ack does, so it is resolved here and never
|
||||
* taken from the body. `resolveParentSessionId()` reads the `X-Codeman-Parent-Session`
|
||||
* header `codeman agent` sends on every request and accepts it only for a live session
|
||||
* the caller can see that has the same owner as the receiver (exact id, or a unique
|
||||
* prefix of at least 8). Anything else is "no session": a post is from the user, and a
|
||||
* read is a peek. What that buys: in multi-user mode a caller cannot name another
|
||||
* owner's session (an admin, who can see every session, can act as any session of
|
||||
* the receiver's owner, as admins can everywhere else). In single-user mode every
|
||||
* local process holds the same credentials, so the header is a hint the client sets
|
||||
* about itself.
|
||||
*
|
||||
* - `POST /api/sessions/:id/inbox` stores a message. `from` is the resolved caller or
|
||||
* null. A full inbox is 409 CONFLICT; the oldest message is never dropped.
|
||||
* - `GET /api/sessions/:id/inbox[?wait=ms]` returns every pending message. Only the
|
||||
* receiver's own read marks them seen. With `wait`, it parks while nothing is
|
||||
* UNSEEN (a seen-but-unacked message does not end the wait, or a receiver holding
|
||||
* one would spin).
|
||||
* - `POST /api/sessions/:id/inbox/ack` removes messages by id, idempotently, and only
|
||||
* for the receiver itself (403 otherwise).
|
||||
* - `GET /api/agent-inbox/summary` reports `pending`, `unseen`, `oldestUnseenAt` and
|
||||
* `parked` per session the caller can see. Counts only, never text, and kept out of
|
||||
* `toState()` so a post does not fan out a `session:updated` to every browser.
|
||||
*
|
||||
* Nothing here writes to a pane.
|
||||
*
|
||||
* @module web/routes/inbox-routes
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { InboxAckSchema, InboxPostSchema, InboxReadQuerySchema } from '../schemas.js';
|
||||
import {
|
||||
abortOnClientHangUp,
|
||||
canAccessOwned,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
resolveParentSessionId,
|
||||
waitCapacityResponse,
|
||||
} from '../route-helpers.js';
|
||||
import { agentInbox, MAX_INBOX_BYTES, MAX_MESSAGE_BYTES, MAX_MESSAGES_PER_INBOX } from '../agent-inbox.js';
|
||||
import { sessionWaits, WaitCapacityError, type InboxWaitResult } from '../session-wait-registry.js';
|
||||
import { clampWaitMs } from '../../config/agent-wait.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/** Per-session counts the summary route reports. */
|
||||
export interface InboxSessionSummary {
|
||||
pending: number;
|
||||
unseen: number;
|
||||
oldestUnseenAt: number | null;
|
||||
/** The receiver itself is parked on `GET …/inbox?wait=` right now. */
|
||||
parked: boolean;
|
||||
}
|
||||
|
||||
export function registerInboxRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
/** The resolved caller is the receiving session itself. */
|
||||
const callerIsReceiver = (req: Parameters<typeof resolveParentSessionId>[1], id: string, owner?: string) =>
|
||||
resolveParentSessionId(ctx, req, undefined, owner) === id;
|
||||
|
||||
app.post('/api/sessions/:id/inbox', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const { text } = parseBody(InboxPostSchema, req.body, 'Invalid inbox message');
|
||||
const from = resolveParentSessionId(ctx, req, undefined, session.owner) ?? null;
|
||||
|
||||
const result = agentInbox.post(id, from, text);
|
||||
if (!result.ok) {
|
||||
switch (result.reason) {
|
||||
case 'empty':
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'text must not be empty');
|
||||
case 'message-too-large':
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Message is larger than ${MAX_MESSAGE_BYTES} bytes (UTF-8). Write it to a file in the workspace and post the path.`
|
||||
);
|
||||
case 'inbox-full-count':
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Inbox of ${id} is full (${MAX_MESSAGES_PER_INBOX} messages). Nothing was stored; the receiver has to ack before more can be posted.`
|
||||
);
|
||||
case 'inbox-full-bytes':
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Inbox of ${id} is full (${MAX_INBOX_BYTES} bytes of pending text). Nothing was stored; the receiver has to ack before more can be posted.`
|
||||
);
|
||||
}
|
||||
}
|
||||
// After the store, so a parked reader that wakes finds the message.
|
||||
sessionWaits.notifyInbox(id);
|
||||
return {
|
||||
success: true,
|
||||
data: { sessionId: id, message: result.message, pending: result.pending, unseen: result.unseen },
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/inbox', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const query = parseBody(InboxReadQuerySchema, req.query);
|
||||
const receiver = callerIsReceiver(req, id, session.owner);
|
||||
// A receiver loops on this URL with identical parameters; a cached 200 would turn
|
||||
// the loop into a busy spin (same reason as GET …/wait).
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
|
||||
let wait: InboxWaitResult | undefined;
|
||||
if (query.wait !== undefined) {
|
||||
const timeoutMs = clampWaitMs(query.wait);
|
||||
if (agentInbox.unseenCount(id) > 0) {
|
||||
wait = {
|
||||
posted: false,
|
||||
timedOut: false,
|
||||
immediate: true,
|
||||
ended: false,
|
||||
aborted: false,
|
||||
waitedMs: 0,
|
||||
timeoutMs,
|
||||
};
|
||||
} else {
|
||||
const abort = abortOnClientHangUp(reply);
|
||||
try {
|
||||
wait = await sessionWaits.waitForInbox(id, {
|
||||
timeoutMs,
|
||||
owner: ownerFor(req),
|
||||
abortSignal: abort.signal,
|
||||
receiver,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read AFTER the wait: this is what the caller gets, so it must be current. A wait
|
||||
// that ended because the session was deleted reads an empty inbox. A client that
|
||||
// hung up never sees this answer, so its read must not mark anything seen.
|
||||
const read = agentInbox.read(id, { receiver: receiver && !wait?.aborted });
|
||||
return { success: true, data: { sessionId: id, receiver, ...read, ...(wait ? { wait } : {}) } };
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/inbox/ack', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const { ids } = parseBody(InboxAckSchema, req.body);
|
||||
if (!callerIsReceiver(req, id, session.owner)) {
|
||||
// A no-op here would let a coordinator believe it cleared a worker's mail, and
|
||||
// would hide a client that forgot the header. Refusing says both out loud.
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
`Only ${id} itself can acknowledge its inbox (X-Codeman-Parent-Session must resolve to it). Reading as anyone else is a peek.`
|
||||
);
|
||||
}
|
||||
const removed = agentInbox.ack(id, ids);
|
||||
return {
|
||||
success: true,
|
||||
data: { sessionId: id, removed, pending: agentInbox.pendingCount(id), unseen: agentInbox.unseenCount(id) },
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/agent-inbox/summary', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
const user = getAuthUser(req);
|
||||
const counts = agentInbox.summary();
|
||||
const sessions: Record<string, InboxSessionSummary> = {};
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (!canAccessOwned(user, session.owner)) continue;
|
||||
const c = counts.get(session.id);
|
||||
const parked = sessionWaits.inboxWaiterCount(session.id, true) > 0;
|
||||
if (!c && !parked) continue;
|
||||
sessions[session.id] = {
|
||||
pending: c?.pending ?? 0,
|
||||
unseen: c?.unseen ?? 0,
|
||||
oldestUnseenAt: c?.oldestUnseenAt ?? null,
|
||||
parked,
|
||||
};
|
||||
}
|
||||
return { success: true, data: { sessions } };
|
||||
});
|
||||
}
|
||||
@@ -6,16 +6,20 @@ export { registerPushRoutes } from './push-routes.js';
|
||||
export { registerTeamRoutes } from './team-routes.js';
|
||||
export { registerMuxRoutes } from './mux-routes.js';
|
||||
export { registerFileRoutes } from './file-routes.js';
|
||||
export { registerHtmlViewRoutes } from './html-view-routes.js';
|
||||
export { registerScheduledRoutes } from './scheduled-routes.js';
|
||||
export { registerCronRoutes } from './cron-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerApprovalRoutes } from './approval-routes.js';
|
||||
export { registerInboxRoutes } from './inbox-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerGitStatusRoutes } from './git-status-routes.js';
|
||||
export { registerEditHistoryRoutes } from './edit-history-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerWorktreeRoutes } from './worktree-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
export { registerRalphRoutes } from './ralph-routes.js';
|
||||
@@ -30,7 +34,9 @@ export { registerVoiceRoutes } from './voice-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export { registerDiscordRoutes } from './discord-routes.js';
|
||||
export { registerDoctorRoutes } from './doctor-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
@@ -43,3 +49,5 @@ export {
|
||||
type CustomModelSwapDisplacement,
|
||||
} from './custom-model-routes.js';
|
||||
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
|
||||
|
||||
export { registerAgentSessionRoutes } from './agent-session-routes.js';
|
||||
@@ -10,8 +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 (Copilot CLI, which is not a registry CLI, takes part when installed
|
||||
* or when its config file exists); one that is enabled but absent from the machine is reported
|
||||
* or already has its config file (Copilot CLI declares its own `mcpConfig`; with its run mode disabled it still
|
||||
* takes part as a sync-only tool 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.
|
||||
*/
|
||||
@@ -39,8 +39,8 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI while its run mode is disabled), which come
|
||||
* last so a registry CLI's definition wins a same-name difference.
|
||||
*/
|
||||
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
|
||||
const registry = enabledClis()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
@@ -16,20 +16,27 @@ import { SseEvent } from '../sse-events.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
findSessionOrFail,
|
||||
parseBody,
|
||||
} from '../route-helpers.js';
|
||||
import { mutateSettingsFile } from '../settings-writer.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../remote-files.js';
|
||||
import type { SessionRemote } from '../../types/session.js';
|
||||
import { isPathWithinRoot } from './file-routes.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
|
||||
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
@@ -190,6 +197,15 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// A remote session's workingDir is a path on the remote host. Writing it with local fs would land
|
||||
// the file in a same-named local folder (or fail), and remote writes are out of scope by design
|
||||
// (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit.
|
||||
if (session.remote) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Writing @fix_plan.md is not supported for a remote (SSH) session'
|
||||
);
|
||||
}
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -226,7 +242,14 @@ export function registerRalphRoutes(
|
||||
const filePath = join(workingDir, '@fix_plan.md');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
// A remote session's workingDir is a path on the remote host: read the file there, over the
|
||||
// same ssh path the File Viewer uses, never a same-named local file.
|
||||
const content = session.remote
|
||||
? await readRemoteFixPlan(session.remote, workingDir, filePath)
|
||||
: await fs.readFile(filePath, 'utf-8');
|
||||
if (content === null) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
|
||||
}
|
||||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
@@ -242,7 +265,8 @@ export function registerRalphRoutes(
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
|
||||
}
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
|
||||
const reason = error instanceof RemoteFileAccessError ? error.message : String(error);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -256,6 +280,13 @@ export function registerRalphRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
|
||||
if (session.remote) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Writing @ralph_prompt.md is not supported for a remote (SSH) session'
|
||||
);
|
||||
}
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -412,8 +443,8 @@ export function registerRalphRoutes(
|
||||
const promptPath = join(casePath, '@ralph_prompt.md');
|
||||
writeFileSync(promptPath, fullPrompt, 'utf-8');
|
||||
|
||||
// Register session
|
||||
await ctx.addSession(session);
|
||||
// Register session (in its case's default session group, if one is set)
|
||||
await ctx.addSession(session, { caseName });
|
||||
ctx.store.incrementSessionsCreated();
|
||||
ctx.persistSessionState(session);
|
||||
await ctx.setupSessionListeners(session);
|
||||
@@ -463,20 +494,17 @@ export function registerRalphRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
// Save lastUsedCase
|
||||
try {
|
||||
let settings: Record<string, unknown> = {};
|
||||
// Save lastUsedCase. It is a key of the server-wide settings.json, readable by every
|
||||
// user through GET /api/settings, so a non-admin's case name is not written there in
|
||||
// multi-user mode (writes are admin-only: PUT /api/settings). isAdmin is always true
|
||||
// in single-user mode.
|
||||
if (isAdmin(req)) {
|
||||
try {
|
||||
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
|
||||
} catch (err) {
|
||||
// Non-critical: the loop starts regardless.
|
||||
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
|
||||
}
|
||||
settings.lastUsedCase = caseName;
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
|
||||
const sessionId = session.id;
|
||||
@@ -513,3 +541,22 @@ export function registerRalphRoutes(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a remote session's `@fix_plan.md` on its host. `null` = no such file, or one that resolves outside
|
||||
* the workspace (the caller's `NOT_FOUND`, as for a local ENOENT); anything that is not a regular file, a
|
||||
* file over the cap, or an ssh failure throws `RemoteFileAccessError`, which the route reports as a failed
|
||||
* read.
|
||||
*/
|
||||
async function readRemoteFixPlan(remote: SessionRemote, workingDir: string, filePath: string): Promise<string | null> {
|
||||
// The file and the workspace in one round trip, so a symlinked @fix_plan.md that resolves outside the
|
||||
// workspace is refused like any missing file (the File Viewer's confinement, file-routes.ts).
|
||||
const [probe, rootProbe] = await remoteProbePaths(remote, [filePath, workingDir]);
|
||||
if (!probe) return null;
|
||||
if (!isPathWithinRoot(rootProbe?.realPath ?? resolve(workingDir), probe.realPath)) return null;
|
||||
if (probe.kind !== 'file') throw new RemoteFileAccessError('@fix_plan.md is not a regular file');
|
||||
if (probe.size > MAX_REMOTE_FIX_PLAN_BYTES) {
|
||||
throw new RemoteFileAccessError(`@fix_plan.md is larger than ${MAX_REMOTE_FIX_PLAN_BYTES / 1024 / 1024}MB`);
|
||||
}
|
||||
return (await remoteReadFile(remote, probe.realPath, MAX_REMOTE_FIX_PLAN_BYTES)).toString('utf-8');
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
|
||||
import { agentSkillDir, applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -201,6 +201,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
|
||||
saved.attachmentHistory,
|
||||
lastSubmitAt: saved.lastSubmitAt,
|
||||
lastRealPromptAt: saved.lastRealPromptAt,
|
||||
keepWarmEnabled: saved.keepWarmEnabled,
|
||||
claudeSessionChain: saved.claudeSessionChain,
|
||||
lastActivityAt: saved.lastActivityAt,
|
||||
owner: saved.owner,
|
||||
@@ -247,7 +249,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
// skill falls back to writing out the whole ~150-line §0 preamble. Remote and
|
||||
// docker sessions never reach here (the plan rejects them as
|
||||
// `remote-or-docker`), so the local-only condition is structural.
|
||||
if (getCli(session.mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
|
||||
if (agentSkillDir(getCli(session.mode)?.capabilities) && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
|
||||
+895
-231
File diff suppressed because it is too large.
Load diff
@@ -20,18 +20,19 @@ import { FastifyInstance } from 'fastify';
|
||||
import { StatusTelemetrySchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parsePromptCache,
|
||||
parseStatusTelemetry,
|
||||
parseSessionStatus,
|
||||
formatSessionStatusText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../../usage-telemetry.js';
|
||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||
import type { SessionPort, EventPort, KeepWarmPort } from '../ports/index.js';
|
||||
import { getCli } from '../../config/cli-registry/index.js';
|
||||
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & KeepWarmPort): void {
|
||||
// Last broadcast telemetry signature per session — the statusline fires on
|
||||
// every assistant message, so we only rebroadcast when the value changes.
|
||||
const lastSig = new Map<string, string>();
|
||||
@@ -50,15 +51,39 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
||||
|
||||
const payload = data as RawStatuslinePayload | undefined;
|
||||
|
||||
// Only a CLI that has a statusline exporter at all speaks for its own model and
|
||||
// prompt-cache state: both ride the SAME statusline payload the exporter POSTs, so
|
||||
// both are gated on capabilities.statusLineTelemetry (the gate that also decides
|
||||
// whether the exporter is injected at all, in tmux-manager._resolveStatusLine).
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
const reportsStatusLine = !!session && !!getCli(session.mode)?.capabilities.statusLineTelemetry;
|
||||
|
||||
// The model the CLI says it is running, for the session headers (displayModel). It
|
||||
// rides every render, so it follows an in-session `/model`; the session dedupes and
|
||||
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
|
||||
// report one here.
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
|
||||
// broadcasts only a change.
|
||||
if (session && reportsStatusLine) {
|
||||
session.noteReportedModel('statusline', payload?.model?.display_name);
|
||||
}
|
||||
|
||||
// Prompt-cache state of the main conversation (Claude Code v2.1.251+): kept on the
|
||||
// session for the Session Options readout and `toState()`, broadcast only when the
|
||||
// report changes (the statusline fires on every assistant message). The broadcast
|
||||
// carries the STORED object, so a compaction stamp set in setPromptCache rides along.
|
||||
// Reports are applied in arrival order. When the exporter wraps a user's own statusline
|
||||
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
|
||||
// arrive out of order; a stale one then briefly overwrites newer state until the next
|
||||
// render corrects it. Accepted for a display-only readout rather than ordered here.
|
||||
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
|
||||
if (promptCache) {
|
||||
if (session?.setPromptCache(promptCache)) {
|
||||
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
|
||||
// Persisted on change, like a model change, so a crash restores the latest report.
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
// Every report moves `expires_at`, so the keep-warm re-evaluates on each one.
|
||||
void ctx.keepWarm.schedule(sessionId);
|
||||
}
|
||||
|
||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||
// present and changed (the statusline fires on every assistant message).
|
||||
const telemetry = parseStatusTelemetry(payload);
|
||||
|
||||
+233
-80
@@ -13,11 +13,12 @@ import { totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { readHostSensors } from '../../host-sensors.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||
import { isExplicitlyEnabled, isUnauthenticatedTunnelAcknowledged } from '../network-auth-policy.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
|
||||
import {
|
||||
ConfigUpdateSchema,
|
||||
SettingsUpdateSchema,
|
||||
@@ -47,15 +48,26 @@ import {
|
||||
toggleService,
|
||||
SETTINGS_PATH,
|
||||
} from '../route-helpers.js';
|
||||
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
|
||||
import { getRepositoryStatus } from '../repo-status.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
|
||||
import type {
|
||||
SessionPort,
|
||||
EventPort,
|
||||
ConfigPort,
|
||||
InfraPort,
|
||||
AuthPort,
|
||||
TabLayoutPort,
|
||||
KeepWarmPort,
|
||||
} from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
||||
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||
import { agentInboxPersistence } from '../agent-inbox-persistence.js';
|
||||
import { agentInbox } from '../agent-inbox.js';
|
||||
|
||||
/**
|
||||
* Defaults for `POST /api/deepseek/install-profile`.
|
||||
@@ -144,9 +156,68 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
|
||||
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
|
||||
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
|
||||
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
|
||||
* admin's cases. Everything else stays readable, because the page reads the synced feature
|
||||
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
|
||||
*/
|
||||
export function settingsForNonAdmin(settings: unknown): unknown {
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return settings;
|
||||
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
|
||||
delete out.caseOrder;
|
||||
delete out.lastUsedCase;
|
||||
const voice = out.voiceSettings;
|
||||
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
|
||||
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
|
||||
delete voiceOut.apiKey;
|
||||
out.voiceSettings = voiceOut;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a login protects the server: CODEMAN_PASSWORD is set, or multi-user mode is
|
||||
* on (every person has their own credential). The tunnel guard and the `loginConfigured`
|
||||
* field of GET /api/tunnel/status both read this, so the page's answer mirrors the guard.
|
||||
*/
|
||||
function isLoginConfigured(): boolean {
|
||||
return !!process.env.CODEMAN_PASSWORD || isMultiUserMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal of a tunnel enable with no login and no tunnel opt-in or acknowledgement: logs
|
||||
* the diagnostic line and returns the message for the 403. `late` is the case where the tunnel
|
||||
* was running when the request arrived and had stopped by the time it would be started; the
|
||||
* request's other settings were saved by then.
|
||||
*/
|
||||
function unauthenticatedTunnelRefusal(late: boolean): string {
|
||||
// Someone who set the network variable to get here before #706 is told why it stopped working.
|
||||
const networkAcknowledged = isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
|
||||
console.warn(
|
||||
`[tunnel] Refused an unauthenticated tunnel ${late ? 'restart after a concurrent stop' : 'enable'} (no login, no tunnel opt-in, no acknowledgement${networkAcknowledged ? '; CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK does not cover the tunnel' : ''})`
|
||||
);
|
||||
return (
|
||||
(late
|
||||
? 'The Cloudflare tunnel stopped while these settings were being saved and was not restarted; ' +
|
||||
'the other settings were saved. '
|
||||
: '') +
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
|
||||
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1, or resend with ' +
|
||||
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.' +
|
||||
(networkAcknowledged
|
||||
? ' CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK is set, but it only acknowledges the network ' +
|
||||
'bind and no longer covers the tunnel.'
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
export function registerSystemRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort
|
||||
): void {
|
||||
const windowStatesPath = dataPath('subagent-window-states.json');
|
||||
const parentMapPath = dataPath('subagent-parents.json');
|
||||
@@ -161,14 +232,26 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Tunnel ==========
|
||||
|
||||
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
|
||||
// `loginConfigured` (#706) tells the page whether a tunnel would have a login in front
|
||||
// of it, so it can ask before an enable even when the server would accept one. It is
|
||||
// the guard's own login test (isLoginConfigured above), never TunnelManager's, which
|
||||
// knows nothing about auth.
|
||||
app.get('/api/tunnel/status', async () => ({
|
||||
...ctx.tunnelManager.getStatus(),
|
||||
loginConfigured: isLoginConfigured(),
|
||||
}));
|
||||
|
||||
app.get('/api/tunnel/info', async () => {
|
||||
app.get('/api/tunnel/info', async (req) => {
|
||||
const status = ctx.tunnelManager.getStatus();
|
||||
const sseClients = ctx.getSseClientCount();
|
||||
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
|
||||
// Multi-user mode: a non-admin is shown only their own logins (address and browser of
|
||||
// every other user's sessions are not theirs to read), matching what their Revoke All
|
||||
// can revoke (POST /api/auth/revoke). null = everyone's (admin, single-user mode).
|
||||
const onlyUser = isMultiUserMode() && !isAdmin(req) ? getAuthUser(req).username : null;
|
||||
if (ctx.authSessions) {
|
||||
for (const [, record] of ctx.authSessions) {
|
||||
if (onlyUser !== null && record.username !== onlyUser) continue;
|
||||
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
|
||||
}
|
||||
}
|
||||
@@ -300,7 +383,10 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== QR Regeneration ==========
|
||||
|
||||
app.post('/api/tunnel/qr/regenerate', async () => {
|
||||
app.post('/api/tunnel/qr/regenerate', async (req, reply) => {
|
||||
// Clears EVERY outstanding QR login code (each user's minted one too) and rotates the
|
||||
// server's token: admin-only in multi-user mode, like the other tunnel controls.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
ctx.tunnelManager.regenerateQrToken();
|
||||
return {};
|
||||
});
|
||||
@@ -309,6 +395,19 @@ export function registerSystemRoutes(
|
||||
|
||||
app.post('/api/auth/revoke', async (req) => {
|
||||
const result = RevokeSessionSchema.safeParse(req.body);
|
||||
// Multi-user mode: a NON-ADMIN revokes only their own login sessions. The bare form
|
||||
// below clears every session on the server, so for them it used to sign out every
|
||||
// user, admins included; a named token is revoked only when it is one of theirs.
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
const username = getAuthUser(req).username;
|
||||
const token = result.success ? result.data.sessionToken : undefined;
|
||||
if (token) {
|
||||
if (ctx.authSessions?.peek(token)?.username === username) ctx.authSessions?.delete(token);
|
||||
} else {
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (result.success && result.data.sessionToken) {
|
||||
ctx.authSessions?.delete(result.data.sessionToken);
|
||||
} else {
|
||||
@@ -327,6 +426,8 @@ export function registerSystemRoutes(
|
||||
// panels can be dragged across the physical monitor seam. macOS only; needs
|
||||
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
|
||||
app.post('/api/system/span-displays', async (req, reply) => {
|
||||
// Launches a browser window on the SERVER's desktop: admin-only in multi-user mode.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
|
||||
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
|
||||
// that errors out invisibly (the toast would otherwise lie "Opening…").
|
||||
@@ -379,7 +480,9 @@ export function registerSystemRoutes(
|
||||
|
||||
// Kick off a detached update to the latest release. Returns immediately; the
|
||||
// browser then polls /api/system/update/status across the service restart.
|
||||
app.post('/api/system/update', async (_req, reply) => {
|
||||
app.post('/api/system/update', async (req, reply) => {
|
||||
// Updates and restarts the server under every user: admin-only in multi-user mode.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const result = await startUpdate();
|
||||
if (result.ok) {
|
||||
return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
|
||||
@@ -617,13 +720,16 @@ export function registerSystemRoutes(
|
||||
// synchronous-spawn precedent this endpoint is modelled on.
|
||||
detached: true,
|
||||
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
|
||||
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
|
||||
// against, and a PATH carrying `pnpm` (see the PATH below). ⚠️ `dsh plugin` does NOT bundle a
|
||||
// package manager — it `spawnSync`s a literal `pnpm` with no npm
|
||||
// fallback, so on a host without one this exits 127 and dsh's own
|
||||
// stderr ("pnpm not found on PATH") is what reaches the caller through
|
||||
// the OPERATION_FAILED detail below. That is the same missing
|
||||
// dependency that broke the docker agent image in issue #352.
|
||||
env: process.env,
|
||||
// PATH leads with the directory `dsh` itself resolved to: CLI management installs
|
||||
// `dsh` and `pnpm` into the user prefix (`~/.local/bin`), which a service's own PATH
|
||||
// usually lacks, so `dsh plugin` could not find the `pnpm` sitting next to it.
|
||||
env: { ...process.env, PATH: [dir, process.env.PATH].filter(Boolean).join(':') },
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
|
||||
@@ -712,13 +818,30 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== GitHub Copilot ==========
|
||||
|
||||
// Carries `version` like pi/grok/omp: `copilot` is a generic binary name, so the resolver
|
||||
// only accepts one whose `--version` says "GitHub Copilot CLI".
|
||||
app.get('/api/copilot/status', async () => {
|
||||
const { isCopilotAvailable, resolveCopilotDir, getCopilotCliVersion } =
|
||||
await import('../../utils/copilot-cli-resolver.js');
|
||||
return {
|
||||
available: isCopilotAvailable(),
|
||||
path: resolveCopilotDir(),
|
||||
version: getCopilotCliVersion(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== State & Lifecycle ==========
|
||||
|
||||
app.post('/api/cleanup-state', async () => {
|
||||
app.post('/api/cleanup-state', async (req, reply) => {
|
||||
// Drops every user's stale session records (and their tab-layout entries): admin-only
|
||||
// in multi-user mode, like the other server-wide maintenance operations.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const activeSessionIds = new Set(ctx.sessions.keys());
|
||||
const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
|
||||
ctx.store.cleanupSessionsByIds(ids)
|
||||
@@ -726,6 +849,14 @@ export function registerSystemRoutes(
|
||||
const lifecycleLog = getLifecycleLog();
|
||||
for (const s of result.cleaned) {
|
||||
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
|
||||
// A detached session kept its agent inbox. Its record going is the delete only
|
||||
// when no tmux session stands behind it: a detached pane the multiplexer still
|
||||
// lists is re-adopted under the same id at the next boot (restoreMuxSessions,
|
||||
// with or without a state.json record), and its mail must come back with it.
|
||||
// Keeping a pane that died while detached errs toward the 12 h TTL and the boot
|
||||
// prune, the same trade the boot keep set makes.
|
||||
if (!ctx.mux.getSession(s.id)) agentInbox.drop(s.id);
|
||||
ctx.forgetEditHistory(s.id);
|
||||
}
|
||||
return { cleanedSessions: result.count };
|
||||
});
|
||||
@@ -866,7 +997,10 @@ export function registerSystemRoutes(
|
||||
return { config: ctx.store.getConfig() };
|
||||
});
|
||||
|
||||
app.put('/api/config', async (req) => {
|
||||
app.put('/api/config', async (req, reply) => {
|
||||
// Process-wide app config in state.json (poll interval, timeouts, the session cap,
|
||||
// respawn defaults): admin-only in multi-user mode, like PUT /api/settings.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
|
||||
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
|
||||
return { config: ctx.store.getConfig() };
|
||||
@@ -929,28 +1063,44 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== System Stats ==========
|
||||
|
||||
app.get('/api/system/stats', async () => {
|
||||
return getSystemStats();
|
||||
// GPU (load, memory, clock) and CPU clock readings are opt-in: the browser adds ?gpu=1 and ?cpumhz=1 only while
|
||||
// the matching header setting is on, so a default install never runs nvidia-smi or reads sysfs.
|
||||
app.get('/api/system/stats', async (req) => {
|
||||
const query = req.query as { gpu?: string; cpumhz?: string };
|
||||
const sensors = await readHostSensors({
|
||||
gpu: query.gpu === '1',
|
||||
cpuMhz: query.cpumhz === '1',
|
||||
});
|
||||
return { ...getSystemStats(), ...sensors };
|
||||
});
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
app.get('/api/settings', async () => {
|
||||
app.get('/api/settings', async (req) => {
|
||||
// A plain read. This route must NEVER write settings.json: readJsonConfig()
|
||||
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
|
||||
// that lands inside PUT's non-atomic write), not only for a missing file,
|
||||
// and every page load calls this route, so a "persist the default when the
|
||||
// key is absent" reconcile here replaced a whole settings file with one key
|
||||
// on the first unlucky read. The plan-usage default is resolved by the
|
||||
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE), not
|
||||
// only for a missing file, and every page load calls this route, so a
|
||||
// "persist the default when the key is absent" reconcile here replaced a
|
||||
// whole settings file with one key on the first unlucky read. The plan-usage default is resolved by the
|
||||
// READERS instead: an absent `showPlanUsageLimits` means ON to
|
||||
// readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent
|
||||
// `workspaceHooksEnabled` means ON, and the client resolves its own display
|
||||
// default through planUsageChipEnabled(). Pinned by
|
||||
// test/routes/system-routes-settings-get-plan-usage-default.test.ts.
|
||||
return readJsonConfig(SETTINGS_PATH, 'settings', {});
|
||||
const settings = await readJsonConfig<unknown>(SETTINGS_PATH, 'settings', {});
|
||||
// Readable by every signed-in user, but a non-admin in multi-user mode gets it without
|
||||
// the admin's credential and case names (settingsForNonAdmin). isAdmin is always true in
|
||||
// single-user mode, so that answer is the file as it is.
|
||||
return isAdmin(req) ? settings : settingsForNonAdmin(settings);
|
||||
});
|
||||
|
||||
app.put('/api/settings', async (req) => {
|
||||
app.put('/api/settings', async (req, reply) => {
|
||||
// Multi-user mode: settings.json is ONE file for the whole server (feature switches,
|
||||
// the plan-usage collection switch, run mode, respawn presets, the tunnel), so a write
|
||||
// is admin-only, like every other server-wide setting (docs/multi-user-plan.md,
|
||||
// Settings row). Refused FIRST: nothing is parsed, written, toggled or started for a
|
||||
// non-admin. GET above stays readable by everyone. No-op in single-user mode.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
|
||||
|
||||
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
|
||||
@@ -958,54 +1108,41 @@ export function registerSystemRoutes(
|
||||
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
|
||||
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
|
||||
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
|
||||
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
|
||||
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in, or an
|
||||
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
|
||||
// confirm dialog). This keeps curl/API/CLI callers protected by default while
|
||||
// letting an operator opt in from the browser without setting the env var.
|
||||
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
|
||||
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
|
||||
// Multi-user mode makes the tunnel authenticated (every person has their own
|
||||
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
|
||||
const acknowledged =
|
||||
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
|
||||
if (!acknowledged) {
|
||||
const msg =
|
||||
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
|
||||
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
|
||||
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
|
||||
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 403,
|
||||
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
|
||||
});
|
||||
}
|
||||
// Loud warning whenever a public tunnel is started with no password — whether
|
||||
// acknowledged via env var or the per-request UI confirmation.
|
||||
if (!process.env.CODEMAN_PASSWORD) {
|
||||
console.warn(
|
||||
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
|
||||
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
|
||||
'Set CODEMAN_PASSWORD to require login.'
|
||||
);
|
||||
}
|
||||
// The tunnel opt-in is CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL / --allow-unauthenticated-tunnel,
|
||||
// never the network one (#706): install.sh writes CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK
|
||||
// for every passwordless LAN install, and that acknowledges the LAN bind only.
|
||||
// Decided ONCE per request, whether or not the tunnel is running now, and required again
|
||||
// where the tunnel is really started below: the settings file awaits in between leave room
|
||||
// for a concurrent stop, and a save that found the tunnel running (so nothing was refused
|
||||
// here) must not then restart it unguarded.
|
||||
const tunnelEnableAuthorized =
|
||||
settings.tunnelEnabled === true &&
|
||||
(isLoginConfigured() ||
|
||||
isUnauthenticatedTunnelAcknowledged(ctx.allowUnauthenticatedTunnel) ||
|
||||
settings.acknowledgeUnauthTunnel === true);
|
||||
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized && !ctx.tunnelManager.isRunning()) {
|
||||
const msg = unauthenticatedTunnelRefusal(false);
|
||||
throw Object.assign(new Error(msg), {
|
||||
statusCode: 403,
|
||||
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
|
||||
// it before persisting so settings.json stays clean.
|
||||
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
// An enable that is not authorized only got this far because the tunnel was running.
|
||||
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
|
||||
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
|
||||
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
|
||||
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
|
||||
// Already-evicted history cannot be recovered on either version.
|
||||
@@ -1023,6 +1160,15 @@ export function registerSystemRoutes(
|
||||
// drift. Same convention as the tmuxHistoryLimit block above.
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
// The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap
|
||||
// makes it re-read them and re-evaluate every session now rather than at its next cache report.
|
||||
// Compared on `merged`, not tested for in the body: the browser sends both keys on every save.
|
||||
if (
|
||||
merged.promptCacheKeepWarm !== existing.promptCacheKeepWarm ||
|
||||
merged.promptCacheKeepWarmMaxHours !== existing.promptCacheKeepWarmMaxHours
|
||||
) {
|
||||
ctx.keepWarm.rescheduleAll();
|
||||
}
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
@@ -1032,11 +1178,16 @@ export function registerSystemRoutes(
|
||||
'Workflow run watcher'
|
||||
);
|
||||
|
||||
// Agent inbox snapshot (default OFF). From `merged` like the toggles above: a
|
||||
// partial PUT that omits the key must not switch it off and delete the file.
|
||||
agentInboxPersistence.setEnabled(merged.agentInboxPersistEnabled === true);
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
// Re-watch all active sessions that have image watcher enabled (never a remote session: its
|
||||
// workingDir is a path on the remote host, not on this disk)
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled) {
|
||||
if (session.imageWatcherEnabled && !session.remote) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
}
|
||||
@@ -1064,6 +1215,20 @@ export function registerSystemRoutes(
|
||||
if ('tunnelEnabled' in settings) {
|
||||
const tunnelEnabled = settings.tunnelEnabled as boolean;
|
||||
if (tunnelEnabled && !ctx.tunnelManager.isRunning()) {
|
||||
// The tunnel stopped since the guard above saw it running: the same authorization applies.
|
||||
if (!tunnelEnableAuthorized) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, unauthenticatedTunnelRefusal(true));
|
||||
}
|
||||
// Loud warning whenever a public tunnel is started with no login in front of it, whether
|
||||
// acknowledged via the tunnel opt-in or the per-request UI confirmation.
|
||||
if (!isLoginConfigured()) {
|
||||
console.warn(
|
||||
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
|
||||
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
|
||||
'Set CODEMAN_PASSWORD to require login.'
|
||||
);
|
||||
}
|
||||
ctx.tunnelManager.start(ctx.port, ctx.https);
|
||||
console.log('Tunnel started via settings change');
|
||||
} else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) {
|
||||
@@ -1078,7 +1243,7 @@ export function registerSystemRoutes(
|
||||
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1089,29 +1254,17 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: settings.modelConfig || {} };
|
||||
});
|
||||
|
||||
app.put('/api/execution/model-config', async (req) => {
|
||||
app.put('/api/execution/model-config', async (req, reply) => {
|
||||
// The `modelConfig` key of the server-wide settings.json (cron's and the plan
|
||||
// orchestrator's default model): admin-only in multi-user mode, like PUT /api/settings.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
try {
|
||||
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
||||
existingSettings = JSON.parse(content);
|
||||
} catch {
|
||||
// File doesn't exist yet, start fresh
|
||||
}
|
||||
|
||||
existingSettings.modelConfig = modelConfig;
|
||||
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2));
|
||||
|
||||
await mutateSettingsFile((current) => ({ ...current, modelConfig }));
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -473,6 +473,7 @@ async function proxyRequest(
|
||||
sessionCookieName: AUTH_COOKIE_NAME,
|
||||
refererPath:
|
||||
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
|
||||
relayBodyLength: hasBody && req.body !== undefined,
|
||||
});
|
||||
|
||||
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules):
|
||||
*
|
||||
* GET /api/cases/:name/worktrees the case repository's worktrees, which of them
|
||||
* Codeman manages, and the live sessions in each
|
||||
* POST /api/cases/:name/worktrees create `<case>/.claude/worktrees/<name>` on a
|
||||
* new branch (`{ name, branch?, base? }`)
|
||||
* DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused
|
||||
* while a live session runs inside; optional
|
||||
* `?deleteBranch=true` is `git branch -d`
|
||||
*
|
||||
* A session starts in a worktree through the EXISTING create paths, never a new spawn path here:
|
||||
* `POST /api/quick-start` with `worktree: <name>` (validated against this module's rules), or
|
||||
* `POST /api/sessions` with the worktree path as `workingDir`.
|
||||
*
|
||||
* The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused
|
||||
* with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder
|
||||
* is on another machine); a linked case resolves for admins only in multi-user mode; anything else
|
||||
* is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed
|
||||
* BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404.
|
||||
*
|
||||
* Statuses are set explicitly from the error code, so the route answers the same with or without
|
||||
* the server's envelope hook.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js';
|
||||
import { readDockerCases } from '../../docker-hosts.js';
|
||||
import { readRemoteCases } from '../../remote-hosts.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { listClis } from '../../config/cli-registry/registry.js';
|
||||
import { agentSkillDir } from '../../hooks-config.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||
import {
|
||||
createCaseWorktree,
|
||||
isPathWithin,
|
||||
listCaseWorktrees,
|
||||
managedWorktreesDir,
|
||||
removeCaseWorktree,
|
||||
resolveCaseRepository,
|
||||
respellUnder,
|
||||
worktreeNameError,
|
||||
type CaseRepository,
|
||||
type CaseWorktree,
|
||||
type CreatedWorktree,
|
||||
type RemovedWorktree,
|
||||
type WorktreeFailure,
|
||||
type WorktreeGitRunner,
|
||||
} from '../../git-worktrees.js';
|
||||
import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js';
|
||||
import {
|
||||
canAccessOwned,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isWorkingDirAllowed,
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
resolveCasesDir,
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */
|
||||
const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
|
||||
|
||||
/**
|
||||
* Every workspace skills folder a session of some registry CLI gets Codeman's agent skill in
|
||||
* (`capabilities.agentSkillDir`), read from the registry at call time as data, never by CLI id.
|
||||
*/
|
||||
const registryAgentSkillDirs = (): string[] => {
|
||||
const dirs = new Set<string>();
|
||||
for (const cli of listClis()) {
|
||||
const dir = agentSkillDir(cli.capabilities);
|
||||
if (dir) dirs.add(dir);
|
||||
}
|
||||
return [...dirs];
|
||||
};
|
||||
|
||||
/** A live session in a worktree, as the list reports it to a caller who may see it. */
|
||||
export interface WorktreeSessionRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One row of `GET /api/cases/:name/worktrees`. */
|
||||
export interface CaseWorktreeInfo extends CaseWorktree {
|
||||
/** Live sessions running in this worktree (deepest worktree wins), all owners. */
|
||||
sessionCount: number;
|
||||
/** The ones the caller may see (all of them outside multi-user mode). */
|
||||
sessions: WorktreeSessionRef[];
|
||||
}
|
||||
|
||||
export interface CaseWorktreeList {
|
||||
caseName: string;
|
||||
repoRoot: string;
|
||||
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
|
||||
worktreesDir: string;
|
||||
worktrees: CaseWorktreeInfo[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
type Fail = ApiResponse<never>;
|
||||
|
||||
function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail {
|
||||
reply.code(httpStatusForErrorCode(code));
|
||||
return createErrorResponse(code, message);
|
||||
}
|
||||
|
||||
function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail {
|
||||
return failWith(reply, ApiErrorCode[failure.code], failure.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's
|
||||
* resolution (remote, then Docker, then linked, then the caller's case space) so a name means
|
||||
* the same case here as it does to the Run button.
|
||||
*/
|
||||
async function resolveLocalCaseFolder(
|
||||
req: FastifyRequest,
|
||||
name: string
|
||||
): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> {
|
||||
if (!SAFE_CASE_NAME.test(name)) {
|
||||
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' };
|
||||
}
|
||||
const user = getAuthUser(req);
|
||||
const configDir = getDataDir();
|
||||
// Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker
|
||||
// case falls through to the caller's own local case instead of shadowing it.
|
||||
if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
|
||||
return {
|
||||
ok: false,
|
||||
code: ApiErrorCode.INVALID_INPUT,
|
||||
message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.',
|
||||
};
|
||||
}
|
||||
if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
|
||||
return {
|
||||
ok: false,
|
||||
code: ApiErrorCode.INVALID_INPUT,
|
||||
message:
|
||||
'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.',
|
||||
};
|
||||
}
|
||||
const linkedCases = await readJsonConfig<Record<string, string>>(dataPath('linked-cases.json'), 'linked cases', {});
|
||||
// Linked cases carry no owner: only admins resolve them in multi-user mode.
|
||||
const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined;
|
||||
const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user));
|
||||
if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' };
|
||||
if (!isWorkingDirAllowed(user, casePath)) {
|
||||
return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' };
|
||||
}
|
||||
const kind = await probePathKind(casePath, { pastCap: true });
|
||||
if (kind === 'unknown') {
|
||||
return {
|
||||
ok: false,
|
||||
code: ApiErrorCode.OPERATION_FAILED,
|
||||
message: describeUnknownPath('Case folder', casePath, { pastCap: true }),
|
||||
};
|
||||
}
|
||||
if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` };
|
||||
if (kind !== 'directory')
|
||||
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' };
|
||||
return { ok: true, casePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Live local sessions, with the folder each runs in as the case's repository spells it
|
||||
* (`respellUnder`: a folder named through the case's own path, which can run through a symlink,
|
||||
* is moved onto the repository's real top level, where git lists the worktrees). Remote sessions
|
||||
* have no local folder.
|
||||
*/
|
||||
function liveLocalSessions(
|
||||
ctx: SessionPort,
|
||||
casePath: string,
|
||||
repoRoot: string
|
||||
): Array<{ id: string; name: string; owner?: string; dir: string }> {
|
||||
const out: Array<{ id: string; name: string; owner?: string; dir: string }> = [];
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue;
|
||||
const dir = respellUnder(session.workingDir, casePath, repoRoot);
|
||||
out.push({ id: session.id, name: session.name, owner: session.owner, dir });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerWorktreeRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort,
|
||||
deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise<string[]> } = {}
|
||||
): void {
|
||||
const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces;
|
||||
|
||||
/**
|
||||
* Case folder → its repository, or the refusal already rendered. `casePath` is the folder as
|
||||
* the case resolves it (possibly through a symlink); `repo.repoRoot` is its real path.
|
||||
*/
|
||||
async function caseRepository(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
name: string
|
||||
): Promise<{ ok: true; repo: CaseRepository; casePath: string; dockerRoots: string[] } | { ok: false; fail: Fail }> {
|
||||
const folder = await resolveLocalCaseFolder(req, name);
|
||||
if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) };
|
||||
const dockerRoots = await dockerWorkspaces();
|
||||
const repo = await resolveCaseRepository(folder.casePath, { git: deps.git, dockerWorkspaces: dockerRoots });
|
||||
if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) };
|
||||
return { ok: true, repo: repo.repo, casePath: folder.casePath, dockerRoots };
|
||||
}
|
||||
|
||||
app.get('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<CaseWorktreeList>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const resolved = await caseRepository(req, reply, name);
|
||||
if (!resolved.ok) return resolved.fail;
|
||||
const { repo, casePath } = resolved;
|
||||
const listed = await listCaseWorktrees(repo, { git: deps.git });
|
||||
if (!listed.ok) return failFrom(reply, listed);
|
||||
|
||||
// Each live session belongs to the DEEPEST worktree holding its folder, so a session in a
|
||||
// managed worktree is not also counted against the main checkout that contains it.
|
||||
const user = getAuthUser(req);
|
||||
const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] }));
|
||||
for (const session of liveLocalSessions(ctx, casePath, repo.repoRoot)) {
|
||||
let best: CaseWorktreeInfo | undefined;
|
||||
for (const row of rows) {
|
||||
if (!isPathWithin(session.dir, row.path)) continue;
|
||||
if (!best || row.path.length > best.path.length) best = row;
|
||||
}
|
||||
if (!best) continue;
|
||||
best.sessionCount += 1;
|
||||
if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
caseName: name,
|
||||
repoRoot: repo.repoRoot,
|
||||
worktreesDir: managedWorktreesDir(repo.repoRoot),
|
||||
worktrees: rows,
|
||||
truncated: listed.truncated,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<{ worktree: CreatedWorktree }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const body = parseBody(CreateWorktreeSchema, req.body);
|
||||
const nameError = worktreeNameError(body.name);
|
||||
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
|
||||
const resolved = await caseRepository(req, reply, name);
|
||||
if (!resolved.ok) return resolved.fail;
|
||||
const created = await createCaseWorktree(resolved.repo, body, {
|
||||
git: deps.git,
|
||||
agentSkillDirs: registryAgentSkillDirs(),
|
||||
});
|
||||
if (!created.ok) return failFrom(reply, created);
|
||||
reply.code(201);
|
||||
return { success: true, data: { worktree: created.worktree } };
|
||||
});
|
||||
|
||||
app.delete(
|
||||
'/api/cases/:name/worktrees/:worktree',
|
||||
async (req, reply): Promise<ApiResponse<{ removed: RemovedWorktree }>> => {
|
||||
const { name, worktree } = req.params as { name: string; worktree: string };
|
||||
const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {});
|
||||
const nameError = worktreeNameError(worktree);
|
||||
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
|
||||
const resolved = await caseRepository(req, reply, name);
|
||||
if (!resolved.ok) return resolved.fail;
|
||||
const removed = await removeCaseWorktree(
|
||||
resolved.repo,
|
||||
{
|
||||
name: worktree,
|
||||
deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1',
|
||||
// Any owner's session blocks: removing the folder under a running agent is the damage
|
||||
// this refusal exists to prevent, whoever started it.
|
||||
isInUse: (path) =>
|
||||
liveLocalSessions(ctx, resolved.casePath, resolved.repo.repoRoot).some((s) => isPathWithin(s.dir, path)),
|
||||
},
|
||||
// The worktree folder itself must not be (or hold) a Docker case workspace either.
|
||||
{ git: deps.git, dockerWorkspaces: resolved.dockerRoots }
|
||||
);
|
||||
if (!removed.ok) return failFrom(reply, removed);
|
||||
return { success: true, data: { removed: removed.removed } };
|
||||
}
|
||||
);
|
||||
}
|
||||
+83
-45
@@ -29,6 +29,11 @@
|
||||
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
|
||||
* optional reliable-delivery tags: the server applies each
|
||||
* (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}.
|
||||
* A frame that arrives while a guarded submit holds the
|
||||
* session's input lease is held, then applied and ACKed in
|
||||
* arrival order once it is released; when that hold queue
|
||||
* is full the socket is closed with 1013 and nothing later
|
||||
* from it is applied, so the client resends in order.
|
||||
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
|
||||
*/
|
||||
|
||||
@@ -169,12 +174,69 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
const sizingToken = Symbol('ws-desktop-sizing');
|
||||
let holdsDesktopClaim = false;
|
||||
|
||||
// Set once an input frame could not be held behind a guarded submit's lease
|
||||
// (the hold queue was full). Nothing later from this socket is applied: it is
|
||||
// closed, and the browser reconnects and resends everything unACKed, in order.
|
||||
let inputStalled = false;
|
||||
|
||||
// Apply one input frame and ACK it. Runs inline, or later and in arrival order
|
||||
// when a guarded submit held the input lease as the frame arrived.
|
||||
const applyInputFrame = (msg: { d: string; seq?: unknown; cid?: unknown }) => {
|
||||
// Reliable delivery: when the frame carries a clientId + seq, apply it
|
||||
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||
// the client can drop it from its durable queue. Frames without seq
|
||||
// (legacy/other tools) are applied as-is — no behavior change.
|
||||
const cid = typeof msg.cid === 'string' ? msg.cid : null;
|
||||
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
|
||||
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
|
||||
let delivered = true;
|
||||
if (apply) {
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
// Browser keystrokes are the user's own, so they may name the tab.
|
||||
delivered = session.write(msg.d, { fromUser: true });
|
||||
// A session whose PTY is gone swallows the write. ACKing anyway told
|
||||
// the client to drop the frame from its durable queue and left the seq
|
||||
// burnt, so the retry that reliable delivery exists for was rejected as
|
||||
// a duplicate: the input was lost for good.
|
||||
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
|
||||
}
|
||||
if (seq !== null && socket.readyState === 1) {
|
||||
if (apply) {
|
||||
if (delivered) socket.send(`{"t":"ia","seq":${seq}}`);
|
||||
} else {
|
||||
// REJECTED as a duplicate. ACK it — the client must still drop it
|
||||
// from its durable queue — but say so, and hand back our watermark.
|
||||
//
|
||||
// A plain ACK here is indistinguishable from "applied", which is
|
||||
// what made a client with a rolled-back counter unrecoverable: its
|
||||
// seqs persist to localStorage on a DEBOUNCED write, so a tab killed
|
||||
// between a send and that write comes back with a counter BELOW this
|
||||
// watermark, every later keystroke lands at or under it, and each one
|
||||
// is dropped-but-ACKed. The UI stays clean, nothing is delivered, and
|
||||
// a reload restores the same stale counter. `last` is what lets the
|
||||
// client lift itself out.
|
||||
// ⚠️ Defensive: the session arrives through a structural port, and an
|
||||
// implementation without this method must not take the whole input
|
||||
// path down with it — a throw here aborts the message handler and the
|
||||
// frame is never ACKed at all, which strands it in the client's queue.
|
||||
const watermark =
|
||||
typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function'
|
||||
? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string)
|
||||
: seq;
|
||||
socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
socket.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (inputStalled) return;
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) {
|
||||
// Refused for good, so say so: a silent return left the frame
|
||||
// unACKed and the client redelivered it every few seconds forever
|
||||
@@ -185,51 +247,27 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Reliable delivery: when the frame carries a clientId + seq, apply it
|
||||
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||
// the client can drop it from its durable queue. Frames without seq
|
||||
// (legacy/other tools) are applied as-is — no behavior change.
|
||||
const cid = typeof msg.cid === 'string' ? msg.cid : null;
|
||||
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
|
||||
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
|
||||
let delivered = true;
|
||||
if (apply) {
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
// Browser keystrokes are the user's own, so they may name the tab.
|
||||
delivered = session.write(msg.d, { fromUser: true });
|
||||
// A session whose PTY is gone swallows the write. ACKing anyway told
|
||||
// the client to drop the frame from its durable queue and left the seq
|
||||
// burnt, so the retry that reliable delivery exists for was rejected as
|
||||
// a duplicate: the input was lost for good.
|
||||
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
|
||||
}
|
||||
if (seq !== null && socket.readyState === 1) {
|
||||
if (apply) {
|
||||
if (delivered) socket.send(`{"t":"ia","seq":${seq}}`);
|
||||
} else {
|
||||
// REJECTED as a duplicate. ACK it — the client must still drop it
|
||||
// from its durable queue — but say so, and hand back our watermark.
|
||||
//
|
||||
// A plain ACK here is indistinguishable from "applied", which is
|
||||
// what made a client with a rolled-back counter unrecoverable: its
|
||||
// seqs persist to localStorage on a DEBOUNCED write, so a tab killed
|
||||
// between a send and that write comes back with a counter BELOW this
|
||||
// watermark, every later keystroke lands at or under it, and each one
|
||||
// is dropped-but-ACKed. The UI stays clean, nothing is delivered, and
|
||||
// a reload restores the same stale counter. `last` is what lets the
|
||||
// client lift itself out.
|
||||
// ⚠️ Defensive: the session arrives through a structural port, and an
|
||||
// implementation without this method must not take the whole input
|
||||
// path down with it — a throw here aborts the message handler and the
|
||||
// frame is never ACKed at all, which strands it in the client's queue.
|
||||
const watermark =
|
||||
typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function'
|
||||
? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string)
|
||||
: seq;
|
||||
socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`);
|
||||
}
|
||||
// A guarded submit (agent-staging.ts) holds the input lease across its
|
||||
// awaited Enter, so frames keep arriving while it is held. Refusing one
|
||||
// lost it: this path does not wait for ACKs, so the next seq landed after
|
||||
// the lease, moved the watermark past the refused one, and its redelivery
|
||||
// was then dropped as a duplicate. Hold the frame instead; the session runs
|
||||
// it, ACK included, in arrival order the moment the lease is released.
|
||||
// ⚠️ Defensive, like `lastInputSeq` below: the session arrives through a
|
||||
// structural port, and one without this method must still deliver input
|
||||
// (a throw here would drop the frame unACKed).
|
||||
const held =
|
||||
typeof session.holdForGuardedLease === 'function'
|
||||
? session.holdForGuardedLease(() => applyInputFrame(msg), msg.d.length, msg.d)
|
||||
: 'free';
|
||||
if (held === 'free') {
|
||||
applyInputFrame(msg);
|
||||
} else if (held === 'full') {
|
||||
// Not held, not applied, not ACKed, and no later frame from this socket
|
||||
// is applied either, so nothing can overtake it.
|
||||
inputStalled = true;
|
||||
console.warn('[ws] input backlog during a guarded submit; closing for redelivery', { sessionId: id });
|
||||
socket.close(1013, 'Input backlog during guarded submit');
|
||||
}
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
|
||||
+354
-6
@@ -20,9 +20,11 @@ import {
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
|
||||
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
|
||||
import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../types/discord.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
import { MAX_MESSAGE_BYTES } from './agent-inbox.js';
|
||||
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import type { SessionMode } from '../types.js';
|
||||
import { isAdvisorModel } from '../types/session.js';
|
||||
@@ -81,6 +83,35 @@ const filesystemPickerPathSchema = z
|
||||
*/
|
||||
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
|
||||
|
||||
/**
|
||||
* Body for the path picker's "New folder": one folder name created directly inside `path`. The name is held
|
||||
* to the character set `safePathSchema` accepts (letters, digits, `_ - . ~` and space, no `..`), so every
|
||||
* name it accepts is one Link Existing and a session working directory accept (the parent's own path is
|
||||
* judged there separately), and never a control character. It is also one path segment, not dot-prefixed (the picker hides those by default) and not tilde-prefixed (a folder named `~` is an `rm -rf ~` accident), and at
|
||||
* most 255 BYTES, the usual filesystem limit.
|
||||
*/
|
||||
export const FilesystemMkdirSchema = z.object({
|
||||
path: filesystemPickerPathSchema,
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Folder name is required')
|
||||
.refine((n) => Buffer.byteLength(n) <= 255, { message: 'Folder name is too long' })
|
||||
.refine((n) => /^[\p{L}\p{N}_\-. ~]+$/u.test(n), {
|
||||
message: 'Folder name can only use letters, numbers, spaces and _ - . ~',
|
||||
})
|
||||
.refine((n) => !n.includes('..') && !n.startsWith('.') && !n.startsWith('~'), {
|
||||
message: 'Folder name cannot contain "..", or start with a dot or a tilde',
|
||||
}),
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
/** Same opt-in flag as browse: lets the picker create inside a hidden folder it is showing. */
|
||||
showHidden: showHiddenQuerySchema,
|
||||
});
|
||||
|
||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||
export const FilesystemBrowseQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema.optional(),
|
||||
@@ -127,6 +158,24 @@ export const FileWriteSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Body validation for `POST /api/sessions/:id/html-view` (render a local HTML file
|
||||
* as a sandboxed page). `path` is absolute or relative to the session workspace;
|
||||
* the extension, containment, guard and broad-root checks are the route's
|
||||
* (html-view-routes.ts), after parsing.
|
||||
*/
|
||||
export const HtmlViewOpenSchema = z
|
||||
.object({
|
||||
path: z
|
||||
.string({ error: 'Missing path parameter' })
|
||||
.min(1, 'Missing path parameter')
|
||||
.max(4096)
|
||||
.refine((p) => !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||
message: 'Invalid path',
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The run-mode ids the API currently accepts: every ENABLED registry entry.
|
||||
*
|
||||
@@ -176,7 +225,7 @@ function sessionModeSchema(): z.ZodType<SessionMode> {
|
||||
/**
|
||||
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
|
||||
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
|
||||
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped.
|
||||
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_`, `OMP_`, `COPILOT_` as shipped.
|
||||
*
|
||||
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
|
||||
* once when the module was imported, which meant a CLI enabled while the server was running
|
||||
@@ -241,7 +290,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_*, COPILOT_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -421,6 +470,28 @@ const GrokConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const CopilotConfigSchema = z
|
||||
.object({
|
||||
// A leading alphanumeric, like resumeSessionId below: a value like `--yolo` would otherwise be read
|
||||
// as its own flag after `--model` (bypassing the allowAll clamp). `/` stays for provider/id.
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/)
|
||||
.optional(),
|
||||
allowAll: z.boolean().optional(),
|
||||
continueSession: z.boolean().optional(),
|
||||
// A single plain word with a leading alphanumeric: --resume also matches session NAMES, so
|
||||
// `my-feature` is accepted, but a value like `--yolo` must not be, because --resume's value is
|
||||
// optional and the next token would be read as its own flag (bypassing the allowAll clamp).
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Schema for OMP CLI-specific configuration.
|
||||
*/
|
||||
@@ -533,12 +604,23 @@ export const DeepSeekWebStartSchema = z
|
||||
*/
|
||||
const parentSessionIdSchema = z.string().max(100).optional();
|
||||
|
||||
/**
|
||||
* Tab group the new session should be placed in (the group's "New session"
|
||||
* action). Advisory like parentSessionId: the layout service looks the id up in
|
||||
* the creating owner's own layout and ignores one it cannot find, so a stale or
|
||||
* foreign id never fails a spawn. A malformed value (null, empty, over-long) is
|
||||
* still a 400, because only the browser sends it.
|
||||
*/
|
||||
const tabGroupIdSchema = z.string().min(1).max(100).optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: sessionModeSchema().optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
/** Session that spawned this one — see parentSessionIdSchema. */
|
||||
parentSessionId: parentSessionIdSchema,
|
||||
/** Tab group to place the new session in: see tabGroupIdSchema. */
|
||||
tabGroupId: tabGroupIdSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -567,6 +649,7 @@ export const CreateSessionSchema = z.object({
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
copilotConfig: CopilotConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
@@ -653,6 +736,43 @@ export const StatusTelemetrySchema = z.object({
|
||||
.nullish(),
|
||||
cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(),
|
||||
model: z.object({ display_name: z.string().max(100).nullish() }).nullish(),
|
||||
// Main-conversation prompt-cache statistics (Claude Code v2.1.251+). Every documented
|
||||
// scalar field is listed nullish: `expires_at`/`hit_ratio`/`last_miss_at`/
|
||||
// `recache_tokens_if_cold` are all documented as possibly null, and the object gains
|
||||
// fields over CLI versions, so a stricter shape would 400 the whole post and blank the
|
||||
// footer (the recurring .optional()/null trap). Causes stay `unknown` (the parser keeps
|
||||
// the strings); `last_miss_cause`'s extra numeric keys and the `miss_causes` record are
|
||||
// stripped by Zod unread, which keeps the stored object bounded — a hostile localhost
|
||||
// payload cannot bloat state, since only the normalized subset below is ever stored.
|
||||
prompt_cache: z
|
||||
.object({
|
||||
warm: z.boolean().nullish(),
|
||||
caching_observed: z.boolean().nullish(),
|
||||
// No length cap: a future TTL longer than today's `5m`/`1h` must not 400 the whole
|
||||
// post and blank the footer. parsePromptCache discards any TTL it does not know, so
|
||||
// the stored value stays bounded to those two regardless.
|
||||
ttl: z.string().nullish(),
|
||||
expires_at: z.number().nullish(),
|
||||
requests: z.number().nullish(),
|
||||
misses: z.number().nullish(),
|
||||
expected_rebuilds: z.number().nullish(),
|
||||
hit_ratio: z.number().nullish(),
|
||||
cache_write_tokens: z.number().nullish(),
|
||||
miss_recache_tokens: z.number().nullish(),
|
||||
last_miss_at: z.number().nullish(),
|
||||
recache_tokens_if_cold: z.number().nullish(),
|
||||
// Keep the first 20 causes instead of 400-ing the whole post (which also drops the
|
||||
// footer, the plan chip and the model report it carries) when Claude Code reports more. The
|
||||
// preprocess slices BEFORE the array validates, so an over-long array costs one bounded
|
||||
// slice, never per-element validation across every entry (parsePromptCache then keeps
|
||||
// the string ones among those 20).
|
||||
last_miss_cause: z
|
||||
.object({
|
||||
causes: z.preprocess((v) => (Array.isArray(v) ? v.slice(0, 20) : v), z.array(z.unknown()).nullish()),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
@@ -695,6 +815,12 @@ export const CloneCaseSchema = z.object({
|
||||
/** `--depth 1`. */
|
||||
shallow: z.boolean().optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
/**
|
||||
* Clone into this exact folder (the folder itself, no case-name subfolder is added) instead of under the cases directory, and
|
||||
* register it as a linked case. Absolute or starting with `~`; only length-bounded here, since
|
||||
* `prepareNewCasePath()` (web/case-path.ts) judges it and produces the user-facing reason.
|
||||
*/
|
||||
path: z.string().min(1).max(1000).optional(),
|
||||
});
|
||||
|
||||
/** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */
|
||||
@@ -702,6 +828,34 @@ export const ClonePreflightSchema = z.object({
|
||||
repository: z.string().min(1).max(2048),
|
||||
});
|
||||
|
||||
// ========== Git worktrees of a case (src/git-worktrees.ts) ==========
|
||||
|
||||
/**
|
||||
* A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or
|
||||
* digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`,
|
||||
* no trailing `.lock`) are `worktreeNameError()`, which every route applies after this.
|
||||
*/
|
||||
const worktreeNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Invalid worktree name. Use letters, digits, ".", "_" and "-".');
|
||||
|
||||
/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */
|
||||
export const CreateWorktreeSchema = z.object({
|
||||
name: worktreeNameSchema,
|
||||
/** New branch name; default `worktree-<name>`, Claude Code's convention. */
|
||||
branch: z.string().min(1).max(200).optional(),
|
||||
/** Start point (branch, tag or commit); default the case's current HEAD. */
|
||||
base: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
/** Query of DELETE /api/cases/:name/worktrees/:worktree. */
|
||||
export const RemoveWorktreeQuerySchema = z.object({
|
||||
/** Also `git branch -d` the worktree's branch (never `-D`). */
|
||||
deleteBranch: z.enum(['true', 'false', '1', '0']).optional(),
|
||||
});
|
||||
|
||||
const RemoteCommandOverridesSchema = z
|
||||
.object({
|
||||
shell: z.string().min(1).max(300).optional(),
|
||||
@@ -1080,6 +1234,15 @@ export const QuickStartSchema = z.object({
|
||||
sessionName: z.string().max(128).optional(),
|
||||
/** Session that spawned this one — see parentSessionIdSchema. */
|
||||
parentSessionId: parentSessionIdSchema,
|
||||
/** Tab group to place the new session in: see tabGroupIdSchema. */
|
||||
tabGroupId: tabGroupIdSchema,
|
||||
/**
|
||||
* Start the session in this git worktree of the case (`<case>/.claude/worktrees/<worktree>`,
|
||||
* created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases
|
||||
* only: refused (400) for remote and Docker cases. The worktree must be a registered worktree
|
||||
* of the case's repository; it is never created here.
|
||||
*/
|
||||
worktree: worktreeNameSchema.optional(),
|
||||
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
|
||||
* Empty string clears. Applied for local AND docker cases (the docker workspace is
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
@@ -1092,6 +1255,7 @@ export const QuickStartSchema = z.object({
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
copilotConfig: CopilotConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
@@ -1322,6 +1486,14 @@ export const SettingsUpdateSchema = z
|
||||
* never added — for a user who wants Codeman to leave their repos alone.
|
||||
*/
|
||||
workspaceHooksEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Launch every Claude session with `--name` set to its tab name (default OFF: only a
|
||||
* name the user chose is pinned). Nothing acts on it in `PUT /api/settings`: it is read
|
||||
* fresh from disk by readPinCliSessionNameEnabled() (hooks-config.ts) at every claude
|
||||
* create and respawn, like readPlanUsageTelemetryEnabled(), so a toggle applies from the
|
||||
* next spawn with no restart.
|
||||
*/
|
||||
pinCliSessionName: z.boolean().optional(),
|
||||
/**
|
||||
* Let browser dictation transcribe through this machine's Claude Code login,
|
||||
* the same speech-to-text service the CLI's own `/voice` mode uses
|
||||
@@ -1348,6 +1520,31 @@ export const SettingsUpdateSchema = z
|
||||
* broadcast and /api/search, which is the user's choice to make.
|
||||
*/
|
||||
autoNameSessions: z.boolean().optional(),
|
||||
/** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */
|
||||
promptCacheKeepWarm: z.boolean().optional(),
|
||||
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
|
||||
promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(),
|
||||
/**
|
||||
* Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel
|
||||
* lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the
|
||||
* server is what runs it: `git fetch` is the only git command the Git panel runs that writes (cloning
|
||||
* and worktree management write too, but only when the user asked for them at the moment they asked), and
|
||||
* the only one that can run later by itself, on a timer, and the only one that can run a repository's own
|
||||
* hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config
|
||||
* commands such as `credential.helper`), so nothing runs on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A
|
||||
* session can override both in Session Options → Git; `null` there clears the override, and sessions
|
||||
* sharing one folder are fetched by one run.
|
||||
*/
|
||||
gitFetchEnabled: z.boolean().optional(),
|
||||
gitFetchIntervalSeconds: z.number().int().min(30).max(3600).optional(),
|
||||
/**
|
||||
* Spawned tabs follow their parent: a session created by another one keeps
|
||||
* following its parent's tab (into its group, moving with it) until moved by
|
||||
* hand. SYNCED, default OFF, because the server does the placement:
|
||||
* TabLayoutService.sessionCreated() reads it at every creation, and with it
|
||||
* off the child lands after its parent once and is hand-placed from then on.
|
||||
*/
|
||||
spawnedTabsFollowParent: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
||||
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
||||
@@ -1378,6 +1575,31 @@ export const SettingsUpdateSchema = z
|
||||
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
|
||||
*/
|
||||
mcpSyncEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Config backups (src/config-backup.ts): timestamped snapshots of the customized config files,
|
||||
* taken at startup and whenever their content changes. SYNCED. Absent means ON (the feature
|
||||
* protects against an event nobody schedules); the folder defaults to `<data dir>/backups/config`.
|
||||
*/
|
||||
configBackupEnabled: z.boolean().optional(),
|
||||
/** Absolute folder (a leading `~` is expanded); empty or absent = the default folder. */
|
||||
configBackupDir: z.string().max(1024).optional(),
|
||||
/** Keep at most this many snapshots. */
|
||||
configBackupKeepCount: z.number().int().min(2).max(500).optional(),
|
||||
/** Also drop snapshots older than this many days; 0 = no age limit. */
|
||||
configBackupKeepDays: z.number().int().min(0).max(3650).optional(),
|
||||
/**
|
||||
* Agent inbox snapshot on disk (src/web/agent-inbox-persistence.ts). SYNCED, default OFF:
|
||||
* off means no file is written, and switching it off deletes the snapshot (the in-memory
|
||||
* inboxes stay). On, pending messages between agents survive a server restart.
|
||||
*/
|
||||
agentInboxPersistEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Experimental agent coordination (src/web/routes/agent-session-routes.ts): bounded
|
||||
* inspection and the guarded stage/submit routes behind `codeman agent inspect|stage|submit`.
|
||||
* SYNCED, default OFF: the routes are labeled experimental and may change in a minor or
|
||||
* patch release, so automation has to opt in. While OFF, all three routes answer 403.
|
||||
*/
|
||||
agentCoordinationEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
@@ -1388,7 +1610,7 @@ export const SettingsUpdateSchema = z
|
||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||
// Lets the UI enable a tunnel after a confirm dialog without the
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/**
|
||||
@@ -1677,6 +1899,37 @@ export const SessionWaitOutputQuerySchema = z.object({
|
||||
timeout: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
// ========== Agent Inbox Routes ==========
|
||||
|
||||
/**
|
||||
* Body for `POST /api/sessions/:id/inbox`. The byte cap (`MAX_MESSAGE_BYTES`) is the
|
||||
* store's, checked there in UTF-8 bytes; this bound is only the outer fence so a
|
||||
* multi-megabyte body never reaches it. It is 4× the byte cap in characters (a UTF-8
|
||||
* character is at most 4 bytes), so every over-size message gets the store's answer,
|
||||
* which tells the sender what to do instead. Multi-line text is fine: it is stored and
|
||||
* handed back as data, never typed.
|
||||
*/
|
||||
export const InboxPostSchema = z
|
||||
.object({
|
||||
text: z
|
||||
.string()
|
||||
.min(1, 'text must not be empty')
|
||||
.max(4 * MAX_MESSAGE_BYTES),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Query for `GET /api/sessions/:id/inbox`. `wait` follows the wait routes' `timeout`: clamped, never a 400 for size. */
|
||||
export const InboxReadQuerySchema = z.object({
|
||||
wait: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
/** Body for `POST /api/sessions/:id/inbox/ack`: the ids a read handed out. */
|
||||
export const InboxAckSchema = z
|
||||
.object({
|
||||
ids: z.array(z.string().min(1).max(64)).min(1).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Session Mutation Routes ==========
|
||||
|
||||
/** PUT /api/sessions/:id/name */
|
||||
@@ -1728,6 +1981,18 @@ export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
|
||||
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
|
||||
|
||||
/**
|
||||
* `POST /api/sessions/:id/git-fetch-settings`: `null` clears the session's choice so the app-wide default
|
||||
* applies. Both fields are optional, so a row that saves on its own does not clear the other.
|
||||
*/
|
||||
export const GitFetchSettingsSchema = z.object({
|
||||
enabled: z.boolean().nullable().optional(),
|
||||
intervalSeconds: z.number().int().min(30).max(3600).nullable().optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
|
||||
export const PinSessionSchema = z.object({
|
||||
pinned: z.boolean(),
|
||||
@@ -1762,7 +2027,7 @@ export const ScheduledRunSchema = z.object({
|
||||
/** 'HH:MM' 24-hour time. */
|
||||
const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)');
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */
|
||||
/** Prompt delivery is single-line only (the submit check cannot reliably recognise a multi-line paste) — reject newlines outright. */
|
||||
const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
||||
|
||||
/** Shared field shape for creating/updating a scheduled job. */
|
||||
@@ -1830,15 +2095,40 @@ export const LinkCaseSchema = z.object({
|
||||
path: safePathSchema,
|
||||
});
|
||||
|
||||
/** POST /api/cases/:name/rename */
|
||||
export const RenameCaseSchema = z
|
||||
.object({
|
||||
newName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
|
||||
/** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */
|
||||
renameFolder: z.boolean().optional(),
|
||||
/** Stop the live sessions that have the folder open, instead of refusing with the list of them. */
|
||||
stopSessions: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** PUT /api/cases/order */
|
||||
export const CaseOrderSchema = z.object({
|
||||
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cases/:name/default-group — the tab group new sessions of this case
|
||||
* land in: one of the caller's own group ids, or null for Ungrouped. Same bounds
|
||||
* as tabGroupIdSchema; the route checks the id against the caller's layout.
|
||||
*/
|
||||
export const CaseDefaultGroupSchema = z.object({
|
||||
groupId: z.string().min(1).max(100).nullable(),
|
||||
});
|
||||
|
||||
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
|
||||
export const SessionOrderUpdateSchema = z.object({
|
||||
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
|
||||
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
|
||||
// its open-tab order (max sessions is 50 by default, at most 462 via CODEMAN_MAX_SESSIONS,
|
||||
// see MAX_CONCURRENT_SESSIONS_CEILING) — 500 leaves enough room while
|
||||
// keeping a hostile/buggy client from persisting megabytes into state.json.
|
||||
order: z.array(z.string().max(100)).max(500),
|
||||
});
|
||||
@@ -1960,6 +2250,37 @@ export const WebhookUpdateSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* PUT /api/discord. `.strict()` and every field optional, so a change of one option never needs the
|
||||
* token re-sent; an empty `token` clears it. Option lists are the bridge's own, so the schema cannot
|
||||
* accept a value the store would coerce away. Ids are checked again (as snowflakes) by the route.
|
||||
*/
|
||||
export const DiscordUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().max(200).optional(),
|
||||
guildId: z.string().max(25).optional(),
|
||||
parentChannelId: z.string().max(25).optional(),
|
||||
allowedUserIds: z.array(z.string().max(25)).max(20).optional(),
|
||||
threadCreation: z.enum(DISCORD_THREAD_CREATION).optional(),
|
||||
onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(),
|
||||
staleThreads: z.enum(DISCORD_ON_DELETE).optional(),
|
||||
pingMode: z.enum(DISCORD_PING_MODES).optional(),
|
||||
scope: z.enum(WEBHOOK_SCOPES).optional(),
|
||||
startupHint: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** POST /api/sessions/:id/discord-notify */
|
||||
export const DiscordNotifySchema = z
|
||||
.object({
|
||||
message: z.string().min(1).max(1500),
|
||||
urgent: z.boolean().optional(),
|
||||
/** Absolute path of a file inside the session's working folder, uploaded with the post. */
|
||||
file: z.string().min(1).max(1024).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ========== Ralph Loop ==========
|
||||
|
||||
/** POST /api/ralph-loop/start */
|
||||
@@ -2098,6 +2419,14 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial();
|
||||
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
||||
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
||||
|
||||
/**
|
||||
* Ceiling on a stated context length (tokens), shared between the bounds below and
|
||||
* discovery (`custom-model-routes.ts`): anything above it reads as nonsense and is
|
||||
* ignored, because a bogus figure here overflows the smaller server, which is the exact
|
||||
* failure the discovery field exists to prevent.
|
||||
*/
|
||||
export const MAX_DISCOVERED_CONTEXT_TOKENS = 100_000_000;
|
||||
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — a
|
||||
// user-configured custom OpenAI-compatible endpoint, local (llama.cpp) or cloud
|
||||
// (Azure AI Foundry, etc.). Lives below `webviewUrlSchema` because `baseUrl` IS that
|
||||
@@ -2121,9 +2450,16 @@ export const CustomModelHostSchema = z.object({
|
||||
defaultModelId: z.string().max(200).optional(),
|
||||
// Server-populated by discovery (custom-model-routes.ts); accepted here only so a client
|
||||
// round-tripping the GET response back through PUT (edit-save) doesn't drop it.
|
||||
modelContextLengths: z.record(z.string().max(200), z.number().int().positive().max(100_000_000)).optional(),
|
||||
modelContextLengths: z
|
||||
.record(z.string().max(200), z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS))
|
||||
.optional(),
|
||||
// Same reasoning as modelContextLengths above.
|
||||
modelSlotCounts: z.record(z.string().max(200), z.number().int().positive().max(4096)).optional(),
|
||||
// Same reasoning as modelContextLengths above.
|
||||
modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(),
|
||||
// User-set fallback for models discovery has no context length for (session-routes.ts);
|
||||
// discovery never writes it. Same bound as the modelContextLengths values above.
|
||||
defaultContextLength: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -2183,3 +2519,15 @@ export const CustomModelSelectionSchema = z.union([
|
||||
}),
|
||||
z.object({ clear: z.literal(true) }),
|
||||
]);
|
||||
|
||||
/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */
|
||||
export const AgentInspectQuerySchema = z
|
||||
.object({
|
||||
maxBytes: z.coerce.number().int().min(1).max(65536).default(8192),
|
||||
since: z.string().uuid().optional(),
|
||||
})
|
||||
.strict();
|
||||
export const AgentStageSchema = z.object({ message: z.string().min(1).max(8192) }).strict();
|
||||
export const AgentSubmitSchema = z
|
||||
.object({ stageId: z.string().uuid(), confirmSha: z.string().regex(/^[a-f0-9]{64}$/) })
|
||||
.strict();
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @fileoverview Screen-detected blocked dialogs: what the server does with them.
|
||||
*
|
||||
* A CLI that has no hook to say "I stopped to ask you something" (codex: `hooks: 'none'`,
|
||||
* and the one CLI Codeman launches with its approvals on by default) declares
|
||||
* `capabilities.workDetect.blockedLine`, and `Session` reads its dialogs off the pane and
|
||||
* emits `blockedChanged` (see `_noteBlockedReading()` in session.ts). This module turns
|
||||
* that edge into exactly what a Claude `permission_prompt` hook produces in
|
||||
* `hook-event-routes.ts`, so every surface that already knows that event (the red tab
|
||||
* alert and desktop notification, web push, webhooks, the phone overview's NEEDS YOU, the
|
||||
* Approvals Inbox, `codeman tui`, the run summary and the `blocked` wait signal) needs no
|
||||
* second code path:
|
||||
*
|
||||
* - `raiseScreenBlocked()`: the `blocked` wait signal, an Approvals Inbox item, the
|
||||
* `hook:permission_prompt` broadcast and the push/webhook.
|
||||
* - `clearScreenBlocked()`: resolve that item, which broadcasts `approval:resolved`, and
|
||||
* every client clears the alert from it (`_onApprovalResolved`, approvals-ui.js).
|
||||
*
|
||||
* Rules it keeps:
|
||||
* - ⚠️ ALERT ONLY. The item is `answerInTerminal` (the #610 shape the DeepSeek Harness
|
||||
* items use): the answer route refuses it, push carries no Approve/Deny, and the inbox
|
||||
* card and the overview strip say "answer in the terminal". The dialog is read
|
||||
* off a screen the agent writes most of, so a forged match must cost a false alert and
|
||||
* never a keystroke. Codeman does not answer these dialogs.
|
||||
* - The item gets no `capture` callback, only the frame it was raised from as context:
|
||||
* the inbox's option parser and staleness sweep are shaped for Claude's dialogs, and the
|
||||
* SESSION owns this item's lifetime (it re-reads the screen while the dialog is up).
|
||||
* - Hooks win. A session whose CLI reports blocked itself (`hooksAvailableForMode()`)
|
||||
* gets nothing here, so a CLI that declared both could never alert twice for one dialog.
|
||||
* - Only the item this module opened is resolved on clear, never a hook-raised one.
|
||||
*
|
||||
* @dependencies approval-inbox, session-wait-registry, sse-events, cli-registry
|
||||
* @consumedby web/session-listener-wiring (blockedChanged)
|
||||
* @module web/screen-blocked
|
||||
*/
|
||||
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
import type { SessionMode } from '../types.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from './session-wait-registry.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
|
||||
/** Marks a screen-read event, so a consumer can tell it from a hook-raised one. */
|
||||
export const SCREEN_BLOCKED_SOURCE = 'screen';
|
||||
|
||||
/** The facts about a session this module reads (structural, so tests need no `Session`). */
|
||||
export interface ScreenBlockedSession {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: SessionMode;
|
||||
deepSeekStatusReporting?: boolean;
|
||||
docker?: unknown;
|
||||
remote?: unknown;
|
||||
}
|
||||
|
||||
/** What the server lends it: the same channels the hook route uses. */
|
||||
export interface ScreenBlockedDeps {
|
||||
broadcast(event: string, data: unknown): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): void;
|
||||
sendPushNotifications(event: string, data: Record<string, unknown>): void;
|
||||
getRunSummaryTracker?(
|
||||
sessionId: string
|
||||
): { recordHookEvent(event: string, data?: Record<string, unknown>): void } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line every surface shows for such a dialog. Built from the CLI's own label (data,
|
||||
* never an id branch), and from nothing on the screen: the dialog's wording is the
|
||||
* agent's to choose (a question's text, a command preview), so none of it reaches a push
|
||||
* or a webhook. The approval card shows the frame itself, escaped.
|
||||
*/
|
||||
export function screenBlockedMessage(mode: SessionMode): string {
|
||||
return `${getCli(mode)?.label ?? mode} is waiting for your answer in the terminal`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane now shows a dialog its CLI is blocked on: raise everything a Claude
|
||||
* `permission_prompt` hook would.
|
||||
*
|
||||
* @param frame the capture the dialog was read from, kept as the card's context
|
||||
* @returns the id of the Approvals Inbox item opened, or null when nothing was raised
|
||||
*/
|
||||
export function raiseScreenBlocked(
|
||||
session: ScreenBlockedSession,
|
||||
deps: ScreenBlockedDeps,
|
||||
frame?: string | null
|
||||
): string | null {
|
||||
if (hooksAvailableForMode(session.mode, sessionHookOptions(session))) return null;
|
||||
const message = screenBlockedMessage(session.mode);
|
||||
|
||||
// Before anything that can throw, like the hook route: a caller parked on `blocked`
|
||||
// is the reason this exists for agents.
|
||||
sessionWaits.notifySignal(session.id, 'blocked');
|
||||
|
||||
const item = approvalInbox.notePrompt({
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
kind: 'permission',
|
||||
message,
|
||||
answerInTerminal: true,
|
||||
context: frame ?? undefined,
|
||||
});
|
||||
|
||||
const data = { source: SCREEN_BLOCKED_SOURCE, message };
|
||||
deps.broadcast(SseEvent.HookPermissionPrompt, {
|
||||
sessionId: session.id,
|
||||
timestamp: Date.now(),
|
||||
...data,
|
||||
approvalId: item.id,
|
||||
});
|
||||
// Same ride-along as the hook route: the home screens rank the blocked group on the
|
||||
// session's stamps, so the row moves without waiting for some unrelated event.
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
// No approvalId: push would offer Approve/Deny for a dialog nobody here may answer.
|
||||
deps.sendPushNotifications(SseEvent.HookPermissionPrompt, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
...data,
|
||||
});
|
||||
deps.getRunSummaryTracker?.(session.id)?.recordHookEvent('permission_prompt', data);
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog left the screen (answered, cancelled, or the pane went away): resolve the
|
||||
* item `raiseScreenBlocked()` opened, which clears the alert on every client. An item
|
||||
* that is no longer that one (dismissed, expired, or superseded by a hook) is left alone.
|
||||
*
|
||||
* @param itemId what `raiseScreenBlocked()` returned
|
||||
*/
|
||||
export function clearScreenBlocked(sessionId: string, itemId: string | null): void {
|
||||
if (!itemId) return;
|
||||
if (approvalInbox.getForSession(sessionId)?.id !== itemId) return;
|
||||
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
|
||||
}
|
||||
@@ -100,6 +100,23 @@ const SENSITIVE_PATTERNS: RegExp[] = [
|
||||
/\/\.codeman[^/]*\/settings\.json$/,
|
||||
/\/\.codeman[^/]*\/push-keys\.json$/,
|
||||
/\/\.codeman[^/]*\/intents\.json$/,
|
||||
// Pending agent-inbox messages (opt-in snapshot): same text as prompts, same 0600.
|
||||
/\/\.codeman[^/]*\/agent-inbox\.json$/,
|
||||
// The edit history panel's stores (edit-history-store.ts) hold every diff an
|
||||
// agent wrote, keys and .env contents included; written 0600 and kept out of
|
||||
// /api/search for the same reason as intents.json.
|
||||
/\/\.codeman[^/]*\/edit-history\//,
|
||||
// Credentials kept outside settings.json: the Discord bot token, the webhook URL
|
||||
// (a bearer secret), the custom endpoints' API keys, and the generated per-session
|
||||
// configs, some of which carry that key inline.
|
||||
/\/\.codeman[^/]*\/discord\.json$/,
|
||||
/\/\.codeman[^/]*\/webhook\.json$/,
|
||||
/\/\.codeman[^/]*\/custom-model-hosts\.json$/,
|
||||
/\/\.codeman[^/]*\/custom-model-configs\//,
|
||||
// Config backups are dated COPIES of the files above (users.json and settings.json
|
||||
// included), which the name-anchored patterns do not match. A custom
|
||||
// `configBackupDir` is outside what a pattern here can name.
|
||||
/\/\.codeman[^/]*\/backups\//,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+627
-43
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { raiseScreenBlocked, clearScreenBlocked } from './screen-blocked.js';
|
||||
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
|
||||
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
|
||||
|
||||
@@ -44,6 +45,7 @@ export interface SessionListenerRefs {
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
watchingChanged: () => void;
|
||||
blockedChanged: (blocked: boolean, frame: string | null) => void;
|
||||
displayModelChanged: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
@@ -91,6 +93,12 @@ interface SessionListenerDeps {
|
||||
updateSessionName(sessionId: string, name: string): boolean;
|
||||
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
|
||||
isAutoNameEnabled(): Promise<boolean>;
|
||||
/**
|
||||
* Re-evaluate the prompt-cache keep-warm when the session goes idle: a turn just ended
|
||||
* (its cache report may already be in), and after a restart this is the first moment the
|
||||
* recovered pane is attached, so the restored report can arm its timer.
|
||||
*/
|
||||
keepWarmSchedule?(sessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +106,8 @@ interface SessionListenerDeps {
|
||||
* Call `attachSessionListeners()` after to wire them to the session.
|
||||
*/
|
||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||
/** The Approvals Inbox item the session's last screen-read dialog opened (see blockedChanged). */
|
||||
let screenBlockedItemId: string | null = null;
|
||||
return {
|
||||
// ─── Terminal Output ─────────────────────────────────────
|
||||
|
||||
@@ -230,7 +240,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
||||
// pending permission/question dialog on the signal ALONE would
|
||||
// false-clear real approvals.
|
||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
// Not for Codeman's own keep-alive turn: only a human spends an idle alert.
|
||||
if (!session.keepAliveTurnActive) approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
// A permission/question dialog gets the pane-VERIFIED variant instead:
|
||||
// the signal only decides when to look, `verifyStillAnswerable` re-reads
|
||||
// the screen and resolves only when the dialog is really gone. Without
|
||||
@@ -240,7 +251,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// open. `stop` was the first thing to clear it, which on a long turn is
|
||||
// minutes away.
|
||||
approvalInbox.resolveIfDialogGone(session.id);
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
// `keepAlive` tells the page this turn is Codeman's own keep-alive (stuck detection ignores it).
|
||||
deps.broadcast(SseEvent.SessionWorking, {
|
||||
id: session.id,
|
||||
...(session.keepAliveTurnActive && { keepAlive: true }),
|
||||
});
|
||||
// Full state ride-along: the home screens sort the running group on
|
||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||
// with (a turn started after page load ranks by the PREVIOUS turn's
|
||||
@@ -256,8 +271,9 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
||||
idle: () => {
|
||||
sessionWaits.notifySignal(session.id, 'idle');
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id, ...(session.keepAliveTurnActive && { keepAlive: true }) });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.keepWarmSchedule?.(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
@@ -276,6 +292,21 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* The pane started or stopped showing a dialog its CLI is blocked on, read off the
|
||||
* screen for a CLI with no hook to report it (`capabilities.workDetect.blockedLine`:
|
||||
* codex). Raised as a Claude `permission_prompt` hook would be, alert-only, and
|
||||
* cleared by resolving the item it opened; web/screen-blocked.ts has the rules.
|
||||
*/
|
||||
blockedChanged: (blocked: boolean, frame: string | null) => {
|
||||
if (blocked) {
|
||||
screenBlockedItemId = raiseScreenBlocked(session, deps, frame);
|
||||
} else {
|
||||
clearScreenBlocked(session.id, screenBlockedItemId);
|
||||
screenBlockedItemId = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pushes the session state when the model the CLI reports changes (its statusline,
|
||||
* or its own footer), which the status does not reflect. Persisted too: a restart
|
||||
@@ -519,6 +550,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('working', refs.working);
|
||||
session.on('idle', refs.idle);
|
||||
session.on('watchingChanged', refs.watchingChanged);
|
||||
session.on('blockedChanged', refs.blockedChanged);
|
||||
session.on('displayModelChanged', refs.displayModelChanged);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
@@ -557,6 +589,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('watchingChanged', refs.watchingChanged);
|
||||
session.off('blockedChanged', refs.blockedChanged);
|
||||
session.off('displayModelChanged', refs.displayModelChanged);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
* lets a request *block* until something happens instead, so an orchestrating
|
||||
* agent can `curl` and wait. Plan: `docs/agent-control-plan.md`.
|
||||
*
|
||||
* Two waiter kinds:
|
||||
* Three waiter kinds:
|
||||
* - **signal waiters** resolve on the first of a requested set of lifecycle
|
||||
* signals (`idle`, `working`, `stop`, `blocked`, `exit`).
|
||||
* - **output waiters** resolve when a literal string appears in a session's
|
||||
* ANSI-stripped output.
|
||||
* - **inbox waiters** resolve when a message is posted to the session's agent
|
||||
* inbox (`agent-inbox.ts`). The registry knows nothing about messages: the
|
||||
* route decides whether to park (nothing unseen) and `notifyInbox()` is the
|
||||
* post's "something arrived". Kept here so `GET .../inbox?wait=` is charged to
|
||||
* the same per-session / per-owner / total caps as the other two.
|
||||
*
|
||||
* ## What "ANSI-stripped output" means here
|
||||
*
|
||||
@@ -28,11 +33,17 @@
|
||||
* `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
|
||||
* stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
|
||||
* pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
|
||||
* and are definitive. Only `claude` mode installs those hooks, so `stop` and
|
||||
* `blocked` never fire for anything else: callers must be rejected up front rather
|
||||
* than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
|
||||
* place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
|
||||
* `shell` is not an external CLI yet installs no hooks either.
|
||||
* and are definitive. Only `claude` mode installs those hooks (and `deepseek`'s status
|
||||
* bridge reports the same two), so `stop` never fires for anything else: callers must
|
||||
* be rejected up front rather than left to hit the timeout. `hooksAvailableForMode()`
|
||||
* keeps that rule in one place, and it is keyed on the MODE rather than on
|
||||
* `isExternalCliMode()` because `shell` is not an external CLI yet installs no hooks
|
||||
* either.
|
||||
*
|
||||
* `blocked` has a second source: a CLI with no hook for it can declare the dialogs it
|
||||
* blocks on (`capabilities.workDetect.blockedLine`, codex), and `Session` reads them off
|
||||
* the screen (`web/screen-blocked.ts`). `blockedSignalAvailable()` is the rule for that
|
||||
* signal. `stop` has no such source, because nothing on a screen says a turn is over.
|
||||
*
|
||||
* ## Ordering contract for the wiring
|
||||
*
|
||||
@@ -152,7 +163,9 @@ export function parseWaitSignals(raw: unknown): ParsedWaitSignals {
|
||||
* `stopped` and `error` both map to `exit` because both mean "not running", so a
|
||||
* caller that does catch one of them does not block. `blocked` is deliberately
|
||||
* underivable here, it exists only as a hook event until it becomes a real
|
||||
* `SessionStatus` (see the deferred Part 3 in the plan).
|
||||
* `SessionStatus` (see the deferred Part 3 in the plan). A screen-read dialog IS a
|
||||
* level a later caller can find (`Session.screenBlocked`), and the wait route answers
|
||||
* that one itself.
|
||||
*/
|
||||
export function signalForStatus(status: SessionStatus): WaitSignal | null {
|
||||
switch (status) {
|
||||
@@ -168,9 +181,6 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
|
||||
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
|
||||
|
||||
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
|
||||
export interface HookCapabilityOptions {
|
||||
/**
|
||||
@@ -266,6 +276,19 @@ export function sessionHookOptions(session: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `blocked` can ever fire for this session: its CLI reports it through hooks
|
||||
* (`hooksAvailableForMode()`), or Codeman reads the dialogs it blocks on off its screen
|
||||
* (`capabilities.workDetect.blockedLine`, codex). The screen half needs no per-session
|
||||
* facts: a remote or Docker pane is still a local tmux pane showing the CLI's screen
|
||||
* (their nested tmux runs with `status off`, so nothing is drawn below the CLI).
|
||||
*
|
||||
* ⚠️ Says nothing about `stop`. A screen shows a dialog; it never says a turn ended.
|
||||
*/
|
||||
export function blockedSignalAvailable(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
|
||||
return hooksAvailableForMode(mode, options) || !!getCli(mode)?.capabilities.workDetect?.blockedLine;
|
||||
}
|
||||
|
||||
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
||||
export interface ResolvedWaitSignals {
|
||||
/** Signals to actually wait on. Empty when `error` is set. */
|
||||
@@ -279,9 +302,9 @@ export interface ResolvedWaitSignals {
|
||||
* every wait endpoint shares:
|
||||
*
|
||||
* 1. An unknown token is an ERROR, never a silent fallback to the default.
|
||||
* 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
|
||||
* no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
|
||||
* never 400.
|
||||
* 2. A signal the session can never emit (`stop` without hooks, `blocked` without
|
||||
* hooks or a screen-read `blockedLine`) is rejected when asked for EXPLICITLY, but
|
||||
* merely dropped from the DEFAULT set: omitting the parameter must never 400.
|
||||
*
|
||||
* Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
|
||||
* not drift; the second-guessing that produces is worse than the duplication.
|
||||
@@ -304,7 +327,9 @@ export function resolveWaitSignals(
|
||||
};
|
||||
}
|
||||
|
||||
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
|
||||
const unsupported = new Set<WaitSignal>();
|
||||
if (!hooksAvailableForMode(options.mode, options)) unsupported.add('stop');
|
||||
if (!blockedSignalAvailable(options.mode, options)) unsupported.add('blocked');
|
||||
|
||||
if (parsed.signals.length === 0) {
|
||||
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
|
||||
@@ -324,7 +349,8 @@ export function resolveWaitSignals(
|
||||
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
|
||||
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
|
||||
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
|
||||
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
|
||||
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). ` +
|
||||
`Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`,
|
||||
};
|
||||
}
|
||||
return { until: parsed.signals, error: null };
|
||||
@@ -369,6 +395,12 @@ export interface OutputWaitResult extends WaitResultBase {
|
||||
snippet: string | null;
|
||||
}
|
||||
|
||||
/** Outcome of an inbox wait. */
|
||||
export interface InboxWaitResult extends WaitResultBase {
|
||||
/** True when a post released the wait (the caller should read the inbox now). */
|
||||
posted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a waiter would exceed a configured cap.
|
||||
*
|
||||
@@ -437,6 +469,15 @@ export interface OutputWaitOptions extends WaitOptionsBase {
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
export interface InboxWaitOptions extends WaitOptionsBase {
|
||||
/**
|
||||
* The caller IS the receiving session (as resolved by the route), not a coordinator
|
||||
* peeking. Counted separately so the inbox summary's `parked` means "the receiver is
|
||||
* listening", which is what a sender deciding whether to nudge needs to know.
|
||||
*/
|
||||
receiver?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionWaitRegistryOptions {
|
||||
maxWaitersPerSession?: number;
|
||||
maxWaitersPerOwner?: number;
|
||||
@@ -462,6 +503,11 @@ interface SignalWaiter extends WaiterBase {
|
||||
settle: (result: SignalWaitResult) => void;
|
||||
}
|
||||
|
||||
interface InboxWaiter extends WaiterBase {
|
||||
receiver: boolean;
|
||||
settle: (result: InboxWaitResult) => void;
|
||||
}
|
||||
|
||||
interface OutputWaiter extends WaiterBase {
|
||||
/** Original-case needle, kept for reporting. */
|
||||
needle: string;
|
||||
@@ -484,6 +530,7 @@ interface OutputWaiter extends WaiterBase {
|
||||
export class SessionWaitRegistry {
|
||||
private readonly signalWaiters = new Map<string, Set<SignalWaiter>>();
|
||||
private readonly outputWaiters = new Map<string, Set<OutputWaiter>>();
|
||||
private readonly inboxWaiters = new Map<string, Set<InboxWaiter>>();
|
||||
/**
|
||||
* Live waiter count per owner. A derived count would mean walking every waiter in
|
||||
* the process on each registration, so it is maintained incrementally instead:
|
||||
@@ -523,20 +570,34 @@ export class SessionWaitRegistry {
|
||||
return this.outputWaiters.get(sessionId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/** Pending waiters of both kinds for a session. */
|
||||
waiterCount(sessionId: string): number {
|
||||
return this.signalWaiterCount(sessionId) + this.outputWaiterCount(sessionId);
|
||||
/**
|
||||
* Pending inbox waiters for a session; with `receiverOnly`, only those whose caller
|
||||
* is the receiving session itself (the inbox summary's `parked`).
|
||||
*/
|
||||
inboxWaiterCount(sessionId: string, receiverOnly = false): number {
|
||||
const set = this.inboxWaiters.get(sessionId);
|
||||
if (!set) return 0;
|
||||
if (!receiverOnly) return set.size;
|
||||
let count = 0;
|
||||
for (const waiter of set) if (waiter.receiver) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Pending waiters of both kinds across every session. */
|
||||
/** Pending waiters of every kind for a session. */
|
||||
waiterCount(sessionId: string): number {
|
||||
return this.signalWaiterCount(sessionId) + this.outputWaiterCount(sessionId) + this.inboxWaiterCount(sessionId);
|
||||
}
|
||||
|
||||
/** Pending waiters of every kind across every session. */
|
||||
totalWaiterCount(): number {
|
||||
let total = 0;
|
||||
for (const set of this.signalWaiters.values()) total += set.size;
|
||||
for (const set of this.outputWaiters.values()) total += set.size;
|
||||
for (const set of this.inboxWaiters.values()) total += set.size;
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Pending waiters of both kinds charged to one owner. */
|
||||
/** Pending waiters of every kind charged to one owner. */
|
||||
ownerWaiterCount(owner: string): number {
|
||||
return this.ownerCounts.get(owner) ?? 0;
|
||||
}
|
||||
@@ -899,6 +960,105 @@ export class SessionWaitRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inbox waits ──
|
||||
|
||||
/**
|
||||
* Block until a message is posted to this session's inbox, the timeout elapses, or
|
||||
* the session ends.
|
||||
*
|
||||
* There is no "already satisfied" shortcut here: whether the inbox holds unseen mail
|
||||
* is the store's business, so the route checks that first and only calls this when
|
||||
* there is nothing to hand out. A post that lands between that check and this call
|
||||
* is not lost either: both run in the same tick, and `notifyInbox` is called by the
|
||||
* post route after the message is stored.
|
||||
*
|
||||
* @throws {WaitCapacityError} when a waiter cap is already reached.
|
||||
*/
|
||||
waitForInbox(sessionId: string, options: InboxWaitOptions): Promise<InboxWaitResult> {
|
||||
const base = { posted: false, timedOut: false, immediate: false, waitedMs: 0, timeoutMs: options.timeoutMs };
|
||||
if (options.abortSignal?.aborted) return Promise.resolve({ ...base, ended: true, aborted: true });
|
||||
if (this.stopped) return Promise.resolve({ ...base, ended: true, aborted: false });
|
||||
|
||||
this.assertCapacity(sessionId, options.owner);
|
||||
|
||||
return new Promise<InboxWaitResult>((resolve) => {
|
||||
let set = this.inboxWaiters.get(sessionId);
|
||||
if (!set) {
|
||||
set = new Set<InboxWaiter>();
|
||||
this.inboxWaiters.set(sessionId, set);
|
||||
}
|
||||
|
||||
const waiter: InboxWaiter = {
|
||||
receiver: options.receiver === true,
|
||||
startedAt: Date.now(),
|
||||
timeoutMs: options.timeoutMs,
|
||||
owner: options.owner,
|
||||
timer: setTimeout(() => {
|
||||
this.removeInboxWaiter(sessionId, waiter);
|
||||
resolve({
|
||||
posted: false,
|
||||
timedOut: true,
|
||||
immediate: false,
|
||||
ended: false,
|
||||
aborted: false,
|
||||
waitedMs: Date.now() - waiter.startedAt,
|
||||
timeoutMs: waiter.timeoutMs,
|
||||
});
|
||||
}, options.timeoutMs),
|
||||
settle: resolve,
|
||||
};
|
||||
set.add(waiter);
|
||||
this.chargeOwner(options.owner);
|
||||
waiter.detachAbort = this.attachAbort(options.abortSignal, () => {
|
||||
this.removeInboxWaiter(sessionId, waiter);
|
||||
resolve({
|
||||
posted: false,
|
||||
timedOut: false,
|
||||
immediate: false,
|
||||
ended: true,
|
||||
aborted: true,
|
||||
waitedMs: Date.now() - waiter.startedAt,
|
||||
timeoutMs: waiter.timeoutMs,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A message was posted to this session's inbox: release every inbox waiter on it.
|
||||
*
|
||||
* @returns how many waiters were resolved.
|
||||
*/
|
||||
notifyInbox(sessionId: string): number {
|
||||
const set = this.inboxWaiters.get(sessionId);
|
||||
if (!set || set.size === 0) return 0;
|
||||
let resolved = 0;
|
||||
for (const waiter of [...set]) {
|
||||
this.removeInboxWaiter(sessionId, waiter);
|
||||
waiter.settle({
|
||||
posted: true,
|
||||
timedOut: false,
|
||||
immediate: false,
|
||||
ended: false,
|
||||
aborted: false,
|
||||
waitedMs: Date.now() - waiter.startedAt,
|
||||
timeoutMs: waiter.timeoutMs,
|
||||
});
|
||||
resolved++;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private removeInboxWaiter(sessionId: string, waiter: InboxWaiter): void {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.detachAbort?.();
|
||||
const set = this.inboxWaiters.get(sessionId);
|
||||
if (!set) return;
|
||||
if (!set.delete(waiter)) return;
|
||||
this.releaseOwner(waiter.owner);
|
||||
if (set.size === 0) this.inboxWaiters.delete(sessionId);
|
||||
}
|
||||
|
||||
// ── Teardown ──
|
||||
|
||||
/**
|
||||
@@ -948,6 +1108,23 @@ export class SessionWaitRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
const inbox = this.inboxWaiters.get(sessionId);
|
||||
if (inbox) {
|
||||
for (const waiter of [...inbox]) {
|
||||
this.removeInboxWaiter(sessionId, waiter);
|
||||
waiter.settle({
|
||||
posted: false,
|
||||
timedOut: false,
|
||||
immediate: false,
|
||||
ended: true,
|
||||
aborted: false,
|
||||
waitedMs: Date.now() - waiter.startedAt,
|
||||
timeoutMs: waiter.timeoutMs,
|
||||
});
|
||||
resolved++;
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingAnsi.delete(sessionId);
|
||||
return resolved;
|
||||
}
|
||||
@@ -978,7 +1155,7 @@ export class SessionWaitRegistry {
|
||||
cancelEverything(): number {
|
||||
this.stopped = true;
|
||||
let resolved = 0;
|
||||
const ids = new Set([...this.signalWaiters.keys(), ...this.outputWaiters.keys()]);
|
||||
const ids = new Set([...this.signalWaiters.keys(), ...this.outputWaiters.keys(), ...this.inboxWaiters.keys()]);
|
||||
for (const id of ids) resolved += this.cancelAll(id);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @fileoverview The writer of the server's `settings.json` (`SETTINGS_PATH`).
|
||||
*
|
||||
* Every read-modify-write of that file goes through `mutateSettingsFile()`, the same shape as
|
||||
* `mutateRegistryFile()` (config/cli-registry/registry-writer.ts). The one direct writer is
|
||||
* config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that
|
||||
* does not parse, which this writer refuses.
|
||||
*
|
||||
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading,
|
||||
* changing and writing before the next starts. The writers used to run unserialized with a
|
||||
* plain truncate-then-write `fs.writeFile`, and quick-start did not even await its
|
||||
* `lastUsedCase` write, so a quick-start followed by a settings save could tear the file (one
|
||||
* write's bytes over the tail of a longer one) or drop the other writer's key.
|
||||
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it,
|
||||
* so a reader never sees a half-written file.
|
||||
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file
|
||||
* that cannot be read or does not parse is left alone and the write throws
|
||||
* `SettingsWriteRefusedError`: the writers used to read such a file as `{}` and write back only
|
||||
* their own keys, which erased every other setting.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../types.js';
|
||||
import { SETTINGS_PATH } from './route-helpers.js';
|
||||
|
||||
export type SettingsFile = Record<string, unknown>;
|
||||
|
||||
/** A write refused because the existing `settings.json` must not be overwritten. The message is user-facing. */
|
||||
export class SettingsWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SettingsWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readSettingsForWrite(path: string): Promise<SettingsFile> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||
throw new SettingsWriteRefusedError(
|
||||
`Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
|
||||
);
|
||||
}
|
||||
// An empty file holds no settings to lose.
|
||||
if (raw.trim() === '') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
// The parser's own message is not repeated: it can quote the file, which may hold an API key.
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new SettingsWriteRefusedError(
|
||||
`${path} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`
|
||||
);
|
||||
}
|
||||
return parsed as SettingsFile;
|
||||
}
|
||||
|
||||
async function writeSettingsFile(path: string, settings: SettingsFile): Promise<void> {
|
||||
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
|
||||
const target = await fs.realpath(path).catch(() => path);
|
||||
await fs.mkdir(dirname(target), { recursive: true });
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Run one change to `settings.json`. `fn` receives the current settings and returns the settings
|
||||
* to write, or `undefined` to write nothing. Resolves with the settings before and after the
|
||||
* change (`after` is `undefined` when nothing was written). A refused or failed mutation rejects
|
||||
* its own caller only; the chain keeps going.
|
||||
*/
|
||||
export function mutateSettingsFile(
|
||||
fn: (current: SettingsFile) => SettingsFile | undefined
|
||||
): Promise<{ before: SettingsFile; after: SettingsFile | undefined }> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const before = await readSettingsForWrite(SETTINGS_PATH).catch((err: unknown) => {
|
||||
console.warn(`[settings] write refused: ${getErrorMessage(err)}`);
|
||||
throw err;
|
||||
});
|
||||
const after = fn({ ...before });
|
||||
if (after) await writeSettingsFile(SETTINGS_PATH, after);
|
||||
return { before, after };
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects with `SettingsWriteRefusedError` when `mutateSettingsFile()` would refuse a write right
|
||||
* now: the writer's own read-for-write rule, run on its own. For a route that does something it
|
||||
* cannot take back (case rename ends sessions) before its settings write. A check only, outside
|
||||
* the chain (writes are atomic, so it never reads a half-written file); the file can still change
|
||||
* before the write.
|
||||
*/
|
||||
export async function assertSettingsWritable(): Promise<void> {
|
||||
await readSettingsForWrite(SETTINGS_PATH);
|
||||
}
|
||||
|
||||
/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */
|
||||
export function settingsWriteErrorResponse(err: unknown): ApiResponse<never> {
|
||||
if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
+19
-5
@@ -5,10 +5,10 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 161 event constants organized by category:
|
||||
* 163 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Transport** (1): sse:heartbeat
|
||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session lifecycle** (25): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
|
||||
@@ -26,12 +26,13 @@
|
||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||
* - **Image / attachments** (2): image:detected, attachment:detected
|
||||
* - **Hooks** (10): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed, prompt_submitted
|
||||
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
|
||||
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook;
|
||||
* permission_prompt also arrives with `source: 'screen'` for a codex dialog read off its screen)
|
||||
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
||||
* - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint)
|
||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||
* - **Clipboard** (1): write
|
||||
* - **Cases** (4): created, linked, deleted, order-changed
|
||||
* - **Cases** (5): created, linked, deleted, renamed, order-changed
|
||||
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
|
||||
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
|
||||
* - **Web tabs** (2): webview:changed, tab:layoutChanged
|
||||
@@ -119,6 +120,10 @@ export const SessionInteractive = 'session:interactive' as const;
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
/** Combined Claude and main Codex plan-usage telemetry for the shared header chip. */
|
||||
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||
/** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */
|
||||
export const SessionPromptCache = 'session:promptCache' as const;
|
||||
/** The prompt-cache keep-warm's status for one session changed (`{ sessionId, ...KeepWarmStatus }`). */
|
||||
export const SessionKeepWarm = 'session:keepWarm' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -363,7 +368,11 @@ export const AttachmentDetected = 'attachment:detected' as const;
|
||||
|
||||
/** Claude Code hook: session idle, waiting for input. */
|
||||
export const HookIdlePrompt = 'hook:idle_prompt' as const;
|
||||
/** Claude Code hook: tool requesting permission. */
|
||||
/**
|
||||
* Claude Code hook: tool requesting permission. Also raised by the server itself, with
|
||||
* `source: 'screen'`, for a dialog read off a hook-less CLI's screen (codex; see
|
||||
* `web/screen-blocked.ts`), which is cleared by `approval:resolved`.
|
||||
*/
|
||||
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
|
||||
/** Claude Code hook: elicitation dialog (Claude asking a question). */
|
||||
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
|
||||
@@ -449,6 +458,8 @@ export const CaseCreated = 'case:created' as const;
|
||||
export const CaseLinked = 'case:linked' as const;
|
||||
/** Case deleted or unlinked. */
|
||||
export const CaseDeleted = 'case:deleted' as const;
|
||||
/** Case renamed (name and folder). */
|
||||
export const CaseRenamed = 'case:renamed' as const;
|
||||
/** Case ordering changed. */
|
||||
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||
|
||||
@@ -526,6 +537,8 @@ export const SseEvent = {
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
SessionPromptCache,
|
||||
SessionKeepWarm,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
@@ -691,6 +704,7 @@ export const SseEvent = {
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
CaseDeleted,
|
||||
CaseRenamed,
|
||||
CaseOrderChanged,
|
||||
|
||||
// Docker cases
|
||||
|
||||
@@ -21,9 +21,13 @@
|
||||
*
|
||||
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
|
||||
* to render the frame. This is the whole point of the proxy.
|
||||
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
|
||||
* already decoded the body. Forwarding them makes the browser try to gunzip
|
||||
* plaintext.
|
||||
* - `content-encoding` / `content-length`: dropped from responses, because
|
||||
* undici's `fetch` already decoded the body. Forwarding them makes the browser
|
||||
* try to gunzip plaintext.
|
||||
* - Request `content-length`: RELAYED with a body. The body reaches undici as a
|
||||
* stream, which it sends chunked unless told the length, and a server that
|
||||
* reads only Content-Length (Python's `http.server`, many small Go/C ones)
|
||||
* then sees an EMPTY body: every POST to such a dashboard arrived blank.
|
||||
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
|
||||
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
|
||||
* Basic-auth header and session cookie to every proxied request. Forwarding
|
||||
@@ -57,8 +61,10 @@ const HOP_BY_HOP = new Set([
|
||||
|
||||
/**
|
||||
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
|
||||
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
|
||||
* `content-length` and `accept-encoding` are recomputed by undici.
|
||||
* carry Codeman's own credentials on a same-origin (trusted) frame; `host` and
|
||||
* `accept-encoding` are recomputed by undici. `content-length` is NOT recomputed
|
||||
* for a streamed body; it is listed here only so the copy loop never relays it
|
||||
* blindly, and `buildUpstreamRequestHeaders` puts it back when a body goes with it.
|
||||
*/
|
||||
const DROP_REQUEST_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
@@ -305,7 +311,7 @@ export function filterCookieHeader(cookie: string | undefined, drop: string[]):
|
||||
export function buildUpstreamRequestHeaders(
|
||||
incoming: Record<string, string | string[] | undefined>,
|
||||
upstream: URL,
|
||||
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string }
|
||||
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string; relayBodyLength?: boolean }
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -316,6 +322,21 @@ export function buildUpstreamRequestHeaders(
|
||||
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
|
||||
// The body is relayed as a stream, and undici frames a stream it has no length
|
||||
// for as `Transfer-Encoding: chunked`. Hand it the browser's own length so the
|
||||
// upstream sees the framing the browser sent. Only alongside a body (a length
|
||||
// with none would leave the upstream waiting for bytes that never come), and
|
||||
// never against a transfer-encoding, which would make the length a lie.
|
||||
const length = incoming['content-length'];
|
||||
if (
|
||||
opts.relayBodyLength &&
|
||||
typeof length === 'string' &&
|
||||
/^\d+$/.test(length) &&
|
||||
incoming['transfer-encoding'] === undefined
|
||||
) {
|
||||
headers['content-length'] = length;
|
||||
}
|
||||
|
||||
if (opts.forwardCookies) {
|
||||
const raw = incoming['cookie'];
|
||||
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
|
||||
|
||||
Reference in new issue
Block a user