Merge remote-tracking branch 'origin/master' into relprep/rtl

This commit is contained in:
Codeman maintainer committed 2026-11-06 04:01:39 +01:00
commit f37f101137
282 files changed
+39362 -1411

No files matched your search

+8 -6
View File
@@ -20,7 +20,7 @@
**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, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **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)
**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)
@@ -30,15 +30,15 @@
## 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: 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.
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. ⚠️ 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)
**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. 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)
**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)
@@ -52,7 +52,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**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`, `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.
**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)
@@ -66,6 +66,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**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)
@@ -82,7 +84,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
⚠️ **`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.
⚠️ **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).
+5 -3
View File
@@ -60,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 =
@@ -106,10 +108,10 @@ export function blockedReason(
// refusing everything below it blocked most of the tree.
for (const dir of ctx.casesDirs ?? []) {
if (absPath === dir) {
return `That is the cases folder itself (${dir}); create a case inside it with plain Create New (no custom folder)`;
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; create it there with plain Create New (no custom folder)`;
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;
@@ -132,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];
}
/**
+497
View File
@@ -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)
)
);
}
}
+16 -3
View File
@@ -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()
+6
View File
@@ -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
+2
View File
@@ -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>;
+10
View File
@@ -10,6 +10,11 @@ import type { SessionPlacementHint } from '../../tab-layout-service.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
/**
* 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
@@ -19,6 +24,11 @@ export interface SessionPort {
*/
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;
+289 -16
View File
@@ -286,6 +286,10 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
// Edit history (edit-history-ui.js): an open panel follows its session's tool ends
// (and its turn ends, from _onSessionIdle: one handler per event in this map)
[SSE_EVENTS.TRANSCRIPT_TOOL_END, '_onEditHistoryTranscriptEvent'],
// Tunnel
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
[SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'],
@@ -2452,9 +2456,15 @@ class CodemanApp {
if (session.id === this.activeSessionId && session.tokens) {
this.updateRespawnTokens(session.tokens);
}
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
// A scheduled fetch publishes the session's result on this same event, so an open Session Options modal
// on that session shows it without polling its own (session-ui.js: updateGitFetchStatus, which leaves the
// control being typed in alone).
if (this.editingSessionId === session.id) this.updateGitFetchStatus(session.id);
// If claudeSessionId was just set, re-check orphan subagents
// This connects subagents that were waiting for the session to identify itself
if (claudeSessionIdJustSet) {
@@ -2874,7 +2884,9 @@ class CodemanApp {
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', async (ev) => {
// The two link kinds whose href is a placeholder the browser cannot open
// (`#`, `#fragment`). Returns true when it took the event.
const openPlaceholderLink = (ev) => {
// File path (_linkifyFilePaths): open it in the preview overlay, which
// resolves workspace and out-of-workspace paths alike.
const pathLink = ev.target.closest('a.rv-path');
@@ -2891,7 +2903,7 @@ class CodemanApp {
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
else if (filePath) this.openFilePreview(target || filePath, sessionId);
return;
return true;
}
// An in-document link (`[Install](#installation)`). The browser must not follow it: with
@@ -2908,8 +2920,18 @@ class CodemanApp {
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
}
return;
return true;
}
return false;
};
// A middle click fires auxclick, never click, so the browser followed the
// placeholder and opened the dashboard root in a new tab: do what a click
// does instead. A real URL keeps the browser's own middle click.
body.addEventListener('auxclick', (ev) => {
if (ev.button === 1) openPlaceholderLink(ev);
});
body.addEventListener('click', async (ev) => {
if (openPlaceholderLink(ev)) return;
// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
@@ -3071,7 +3093,27 @@ class CodemanApp {
const targets = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue;
const found = findFilePathLinks(node.nodeValue || '');
// Markdown puts **Saved to:** in a separate <strong> node. Carry a bold
// label that starts its line into detection and let spacedFilePathOnLine()
// judge it, as it does the same line in the terminal (`Note:` is not a
// path label); never a label from another block. Chat renders with
// `breaks: true`, so a later line of a paragraph starts after a <br>.
// A text node after any other inline element (`Use <code>--config</code>
// /a/b c.json`) starts mid-line, so a placeholder word stands in for what
// precedes it. The label itself stays untouched.
const label = node.previousSibling;
let beforeLabel = label?.previousSibling;
if (beforeLabel?.nodeType === 3 && !beforeLabel.nodeValue.trim()) beforeLabel = beforeLabel.previousSibling;
const leadingLabel =
label?.nodeName === 'STRONG' &&
/^(?:P|LI)$/.test(node.parentElement?.tagName || '') &&
(!beforeLabel || beforeLabel.nodeName === 'BR');
let prefix = '';
if (leadingLabel) prefix = `${label.textContent} `;
else if (label?.nodeType === 1 && label.nodeName !== 'BR') prefix = 'x ';
const found = findFilePathLinks(prefix + (node.nodeValue || ''))
.filter((hit) => hit.index >= prefix.length)
.map((hit) => ({ ...hit, index: hit.index - prefix.length }));
if (found.length > 0) targets.push([node, found]);
}
@@ -3575,6 +3617,9 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// An open Edit history panel re-reads at the end of its session's turn (edit-history-ui.js),
// not after Codeman's own keep-alive turn, which edits nothing.
if (!data.keepAlive) this.onEditHistorySessionIdle?.(data.id);
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
// turn, whose timer is still running or has already fired.
@@ -5192,6 +5237,8 @@ class CodemanApp {
// backoff reconnect, but if it is not on this session (dead socket, or
// a give-up) nothing else would re-establish it from here.
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
// The fresh session record may no longer qualify for a keyboard row hold.
this._reconcileKeyboardHold?.();
void this._onSessionNeedsRefresh({ id: restoreId });
// The split pane's second terminal reconnects on its own backoff (up to
// 10 s between tries); the server is back now, so skip the wait. A no-op
@@ -5936,7 +5983,7 @@ class CodemanApp {
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
// for a frame before the next measure.
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap');
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap', 'tabs-corner');
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
// here too — not only at the render tails.
this.applySidebarFilter(this._sidebarFilter);
@@ -6927,7 +6974,14 @@ class CodemanApp {
updateTabOverflowMode() {
const container = this.$('sessionTabs');
if (!container) return;
// The corner takes the toolbar out of the header's flow, which widens the
// strip, so it goes before the wrap is measured and comes back once decided.
container.classList.remove('tabs-corner');
this._decideTabOverflow(container);
this._syncTabStripCorner(container);
}
_decideTabOverflow(container) {
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
@@ -6989,6 +7043,108 @@ class CodemanApp {
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}
/**
* L-shaped header strip: once the classic strip wraps, the toolbar
* (#headerRight) sits over the strip's top-right corner (styles.css,
* `.tabs-corner`) and the rows under the first one run the header's full
* width beneath it. The strip keeps the toolbar's corner clear with a float
* sized here, from the toolbar's own box, so a toolbar that wraps onto more
* lines (header-divider.js) or gains a reading keeps every tab out from
* under it. The grouped arrangements (state, case, ledger) lay their rows
* out themselves and keep the plain strip.
*/
_syncTabStripCorner(container) {
const right = this.$('headerRight');
const on =
!!right &&
container.offsetParent !== null &&
!!container.parentElement?.classList.contains('session-tabs-host') &&
(container.classList.contains('tabs-auto-wrap') || container.classList.contains('tabs-two-rows')) &&
!container.classList.contains('tabs-triage') &&
!container.classList.contains('tabs-clusters') &&
!container.classList.contains('tabs-ledger') &&
MobileDetection.getDeviceType() === 'desktop';
const off = () => {
container.classList.remove('tabs-corner');
container.style.removeProperty('--tab-corner-width');
container.style.removeProperty('--tab-corner-height');
container.style.removeProperty('--tab-corner-first-max');
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) tab.classList.remove('tab-corner-fit');
};
// A strip past its max-height scrolls, and the float would scroll its tabs
// under the toolbar, which stays put: keep the plain wrap. The plain strip
// is measured here (updateTabOverflowMode took .tabs-corner off), before
// the class goes on; toggling it on a scrolled strip lets scroll anchoring
// move the strip.
if (!on || container.scrollHeight > container.clientHeight + 1) {
off();
return;
}
container.classList.add('tabs-corner');
if (!this._tabCornerObserver && typeof ResizeObserver !== 'undefined') {
let frame = 0;
// A toolbar that changes size (a reading arrives, the divider settles)
// moves the tabs without a render: re-decide the wrap and the corner, and
// redraw the lineage routes that follow the tabs.
this._tabCornerObserver = new ResizeObserver(() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const strip = this.$('sessionTabs');
if (!strip || !/\btabs-(corner|auto-wrap|two-rows)\b/.test(strip.className)) return;
this.updateTabOverflowMode();
this.updateConnectionLines?.();
});
});
this._tabCornerObserver.observe(right);
}
const style = getComputedStyle(container);
const strip = container.getBoundingClientRect();
const box = right.getBoundingClientRect();
// The float sits at the strip's content padding edge (the plain strip did
// not scroll, so scrollTop is 0).
const contentTop = strip.top + parseFloat(style.paddingTop);
const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft);
const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight);
// The first tab must fit beside the toolbar, or the whole first line drops
// below it and leaves an empty band. A selected tab is wider (gear and
// close show), so falling back to the plain strip here made the layout
// follow the selection; the first tab's name is trimmed to the room
// instead. Only a toolbar that leaves no usable room keeps the plain strip.
// The room is what the float (rounded up below) leaves, less the tab's 2px
// right margin and a pixel for subpixel layout; a tab at the exact width
// still wrapped under the float.
const floatWidth = Math.max(0, Math.ceil(contentRight - box.left + 8));
const room = Math.floor(contentRight - floatWidth - contentLeft) - 3;
if (room < TAB_CORNER_MIN_FIRST_TAB) {
off();
return;
}
const firstTab = container.querySelector(':scope > .session-tab');
// Capped every time, not only once it measures too wide: selectSession()
// widens a newly selected tab at once and this pass is debounced, so a cap
// set only here afterwards would leave the first row below the toolbar until
// it runs. The cap changes nothing on a tab narrower than the room.
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) {
if (tab !== firstTab) tab.classList.remove('tab-corner-fit');
}
firstTab?.classList.add('tab-corner-fit');
if (container.style.getPropertyValue('--tab-corner-first-max') !== `${room}px`) {
container.style.setProperty('--tab-corner-first-max', `${room}px`);
}
const width = `${floatWidth}px`;
const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`;
if (container.style.getPropertyValue('--tab-corner-width') !== width) {
container.style.setProperty('--tab-corner-width', width);
}
if (container.style.getPropertyValue('--tab-corner-height') !== height) {
container.style.setProperty('--tab-corner-height', height);
}
// A plain strip just under its max-height can pass it once the float is in:
// fall back to the plain strip, which fit.
if (container.scrollHeight > container.clientHeight + 1) off();
}
/**
* True when a case cluster in the header strip wraps inside its own box: a case
* wider than the whole strip (styles.css caps a box at the strip's width). The
@@ -7167,7 +7323,9 @@ class CodemanApp {
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
// The "+" announces a menu, so Enter and Space open it: the strip's own key
// handler acts only on a focused tab, and the header strip has no row menu.
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-new" onclick="event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))})" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))}); }" title="New session here" aria-label="New session in this tab's case" aria-haspopup="menu" tabindex="0">+</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
@@ -8396,12 +8554,100 @@ class CodemanApp {
return true;
}
/**
* A tab's "+" (and the row menu's "New session here…"): the CLIs that tab's
* case can start, each as its logo and name like the Run menus. A pick starts
* ONE session beside that tab: its case (or the managed worktree it runs in)
* and its named group, if it has one, through `_launchOnce`, so the Run
* button's mode and case are untouched.
*/
openTabNewSessionMenu(event, sessionId) {
const session = this.sessions.get(sessionId);
const worktree = this._managedWorktreeOfSession?.(session) || null;
const caseEntry = worktree ? null : this._tabCaseOf(session);
if (!worktree && !caseEntry) {
event?.stopPropagation?.();
this.showToast?.("This tab's folder is not a case, so there is nothing to start beside it.", 'info');
return false;
}
const caseName = worktree ? worktree.caseName : caseEntry.name;
// An ungrouped tab sends no group, so the server's case default group (if the
// case has one) places the new session, as it does for a toolbar Run.
const tabGroupId = this._tabRefLocation({ kind: 'session', id: sessionId })?.groupId || null;
// The toolbar probes only the case it has selected, so ask this tab's container
// now: this open reads what is already known, the next one the container's answer.
if (caseEntry?.location === 'docker' && !this._dockerCaseModes?.[caseName]) {
void this._probeDockerCaseModes(caseEntry, null);
}
// The phone overview's list, gated by the Run menu's own rule for this case
// (_runModeAvailableForCase: the container's CLIs for a Docker case; shell is
// never gated), in the Run menu's order: agents in catalog order, the shell last.
const isShell = (entry) => registryCliById(entry.mode)?.kind === 'shell';
const available = mobileOverviewRunModes().filter(
(entry) => isShell(entry) || this._runModeAvailableForCase(entry.mode, caseName)
);
const modes = [...available.filter((entry) => !isShell(entry)), ...available.filter(isShell)];
const current = this.runMode;
const actions = modes.map((entry) => ({
label: entry.label,
icon: entry.mode,
className: 'run-mode-option',
selected: entry.mode === current,
run: () =>
this._launchOnce(entry.mode, {
...(worktree ? { worktree } : { caseName }),
...(tabGroupId ? { tabGroupId } : {}),
}),
}));
return this._openTabLayoutMenu(event, `new:${sessionId}`, 'New session here', actions, { anyLayout: true });
}
/**
* The case a session tab belongs to, by what the session runs on. GET /api/cases
* gives a remote or Docker case a DISPLAY path (`user@host:/path`,
* `container:/path`) with the raw values under `remote` / `docker`, while a
* session carries its raw folder, so a path match alone misses those cases or
* lands on a local case at the same folder. A Docker session belongs to its
* container's case on its own engine host, a remote one to the case on its own
* host whose path encloses its own, a local one to the deepest case enclosing
* its folder (a display path never encloses one). Null when none fits.
*/
_tabCaseOf(session) {
if (!session) return null;
const cases = this.cases || [];
const deepestEnclosing = (dir, candidates, rootOf) => {
let best = null;
let bestLength = -1;
for (const c of candidates) {
const root = (rootOf(c) || '').replace(/\/+$/, '');
if (!root || (dir !== root && !dir.startsWith(`${root}/`)) || root.length <= bestLength) continue;
best = c;
bestLength = root.length;
}
return best;
};
if (session.docker) {
const { hostId, containerName } = session.docker;
return (
cases.find((c) => c.location === 'docker' && c.docker?.hostId === hostId && c.docker?.container === containerName) ||
null
);
}
if (session.remote) {
const { hostId, remotePath } = session.remote;
const onHost = cases.filter((c) => c.location === 'remote' && c.remote?.hostId === hostId);
return deepestEnclosing((remotePath || '').replace(/\/+$/, ''), onHost, (c) => c.remote.path);
}
return deepestEnclosing((session.workingDir || '').replace(/\/+$/, ''), cases, (c) => c.path);
}
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
/**
* Close the open group / web-tab menu. Every dismissal path lands here:
* Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an
* action, and any full re-render of the rail (which would orphan its trigger).
* Close the open group / web-tab / tab "+" menu. Every dismissal path lands
* here: Escape, a pointer outside it, focus moving to an element outside it,
* Tab, a viewport resize, an action, and any full re-render of the rail (which
* would orphan its trigger).
*/
closeTabGroupMenu({ restoreFocus = false } = {}) {
const menu = this._tabGroupMenu;
@@ -8461,7 +8707,7 @@ class CodemanApp {
]);
}
_openTabLayoutMenu(event, identity, ariaLabel, actions) {
_openTabLayoutMenu(event, identity, ariaLabel, actions, { anyLayout = false } = {}) {
event?.preventDefault?.();
event?.stopPropagation?.();
const trigger = event?.currentTarget || null;
@@ -8472,7 +8718,7 @@ class CodemanApp {
}
this.closeTabGroupMenu();
this.closeTabRailActionMenu?.();
if (!this._tabLayoutEditable()) return false;
if (!anyLayout && !this._tabLayoutEditable()) return false;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu tab-layout-group-action-menu';
menu.setAttribute('role', 'menu');
@@ -8481,8 +8727,17 @@ class CodemanApp {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.icon) {
// A Run menu entry's markup (renderRegistryRunOptions): the CLI's logo, then its name.
const dot = document.createElement('span');
dot.className = `run-mode-dot ${action.icon}`;
dot.setAttribute('aria-hidden', 'true');
button.append(dot, action.label);
} else {
button.textContent = action.label;
}
if (action.className) button.className = action.className;
if (action.selected) button.classList.add('selected');
button.addEventListener('click', () => {
this.closeTabGroupMenu();
action.run();
@@ -8517,6 +8772,12 @@ class CodemanApp {
document.addEventListener('keydown', this._tabGroupMenuKeydown, true);
window.addEventListener('resize', this._tabGroupMenuResize);
menu.addEventListener('keydown', (keyEvent) => {
// A held Enter or Space that opened the menu repeats onto the entry it
// focuses; cancelling the repeat keeps it from also picking that entry.
if (keyEvent.repeat && (keyEvent.key === 'Enter' || keyEvent.key === ' ')) {
keyEvent.preventDefault();
return;
}
const buttons = [...menu.querySelectorAll('button')];
const at = buttons.indexOf(document.activeElement);
if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
@@ -8531,13 +8792,20 @@ class CodemanApp {
this.closeTabGroupMenu({ restoreFocus: true });
}
});
// Focus leaving by any other route (a click elsewhere, a programmatic move).
// Hops between the menu's own items are not a departure.
// Focus moving to another element outside the menu (a programmatic move;
// a click elsewhere is the pointerdown listener's). Hops between the
// menu's own items are not a departure, and neither is focus going nowhere:
// Safari never focuses a clicked button, so a press on an item blurs the
// focused one with no relatedTarget, and closing then dropped its click
// (the tile count menu's rule, tile-grid.js). Nor is focus landing on the
// trigger: a press on a focusable one (a tab's "+") focuses it before its
// click, and closing there let that click open the menu again.
menu.addEventListener('focusout', (focusEvent) => {
if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return;
const to = focusEvent.relatedTarget;
if (!to || menu.contains(to) || trigger?.contains?.(to)) return;
if (this._tabGroupMenu === menu) this.closeTabGroupMenu();
});
menu.querySelector('button')?.focus();
(menu.querySelector('button.selected') || menu.querySelector('button'))?.focus();
return true;
}
@@ -9729,6 +9997,9 @@ class CodemanApp {
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
this._activateFileBrowserSession?.(sessionId);
// A keyboard row hold belongs to the session it was taken for: release it here
// if this one resizes instead (the geometry work below then fits it).
this._reconcileKeyboardHold?.({ resize: false });
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258). The switch lands at
@@ -9770,6 +10041,8 @@ class CodemanApp {
// Remote-host reachability banner: only meaningful for a remote session, so this
// also clears it when the newly active tab is local.
this.refreshHostWakeBanner?.(sessionId);
// An open Edit history panel follows the active session (edit-history-ui.js; no poll).
this.onEditHistoryActiveSession?.(sessionId);
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
// the async buffer load. Without this, the offset is 0 during the
+712 -24
View File
@@ -281,6 +281,11 @@ function resolveTabRailKeyboardWidth(input = {}) {
// the gap is what tells the user there is more strip to swipe to.
const TAB_SCROLL_REVEAL_PX = 16;
// Narrowest room beside the toolbar the L-shaped header strip trims its first
// tab into (`_syncTabStripCorner()`, app.js). Less than this keeps the plain
// wrap: a tab squeezed further shows its number and buttons but no name.
const TAB_CORNER_MIN_FIRST_TAB = 120;
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
// the strip horizontally (desktop wraps to a second row instead and never
// scrolls), so the active tab can sit entirely outside the visible slice with
@@ -2106,7 +2111,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -2159,18 +2164,21 @@ function previewsInFileViewer(filePath) {
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
/**
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
* then `~/` and relative ones, then folder paths — each only where it overlaps
* neither an earlier one nor a URL.
* Every linkable file path in `text`, sorted by position. Spaced candidates
* reserve their whole span first, including paths bounded by parentheses, even when unlinkable; rooted, `~/`, relative
* and folder detectors then skip reserved spans, earlier matches and URLs.
*
* The one entry point for both consumers (terminal link provider, response
* viewer), so a path form is clickable in both or in neither.
* viewer). The viewer carries a leading bold label into the text it passes
* (spacedFilePathOnLine judges it, as for the same line in the terminal) and
* reads a text node that follows another inline element as mid-line; paths
* split across inline elements are still detected per text node.
*
* @param {string} text
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
@@ -2184,32 +2192,178 @@ function findFilePathLinks(text) {
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
// (`file:///home/x.png` is not one of them: its path is the file.)
const urls = [];
const urlPattern = /\bhttps?:\/\/\S+/gi;
const webUrlPattern = /\bhttps?:\/\/\S+/gi;
let match;
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
while ((match = webUrlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
// A path with spaces is taken whole, or the span it covers links nothing
// (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it.
const reserved = [];
const isReserved = (index, length) =>
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
const reserve = (spaced, index) => {
if (!spaced) return;
const start = index + spaced.index;
if (inUrl(start, spaced.path.length) || isReserved(start, spaced.path.length)) return;
reserved.push({ index: start, path: spaced.path });
if (spaced.link) found.push({ path: spaced.path, index: start });
};
let lineStart = 0;
for (const line of value.split('\n')) {
reserve(spacedFilePathOnLine(line), lineStart);
// Parentheses bound an inline path just as a whole line bounds a standalone path.
const parenthesised = /\(([^()\r\n]{1,1000})\)/g;
while ((match = parenthesised.exec(line)) !== null) {
if (opensBashCall(line, match.index)) continue;
reserve(spacedFilePathOnLine(match[1], { parenthesised: true }), lineStart + match.index + 1);
}
lineStart += line.length + 1;
}
const absolute = absoluteFilePathPattern();
while ((match = absolute.exec(value)) !== null) {
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) {
found.push({ path: match[1], index: match.index });
}
}
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
while ((match = relative.exec(value)) !== null) {
const path = match[2];
const index = match.index + match[1].length;
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
if (overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
found.push({ path, index });
}
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
while ((match = folders.exec(value)) !== null) {
const path = folderLinkPath(match[2]);
const index = match.index + match[1].length;
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
if (!path || overlaps(index, path.length) || inUrl(index, path.length) || isReserved(index, path.length)) continue;
found.push({ path, index, folder: true });
}
return found.sort((a, b) => a.index - b.index);
}
/** Labels that explicitly identify a path, for {@link spacedFilePathOnLine}. */
const SPACED_FILE_PATH_LABEL_PATTERN =
/^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i;
/**
* True when the `(` at `open` starts a `Bash(` tool row, which holds a command rather than
* a path: `Bash(/tmp/a.out input.txt)` is a program and its argument, so the parentheses do
* not bound one spaced path and the ordinary detectors keep the row. Only `Bash(`: the path
* tools (`Read(`, `Update(`, `Write(`) do hold a path, spaces included.
*
* @param {string} text
* @param {number} open Index of the `(`.
* @returns {boolean}
*/
function opensBashCall(text, open) {
return /(?:^|\W)Bash\($/.test(text.slice(Math.max(0, open - 5), open + 1));
}
/**
* A file path WITH SPACES on one line, or null.
*
* Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx`
* linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the
* wrong targets. A space is only safe inside a path where the line itself bounds
* the path or a parenthesised group bounds it, so a spaced path is recognised in these shapes:
*
* Updated (/mnt/c/My Docs/report.docx) inside parentheses in prose
* /mnt/c/My Docs/report.docx the whole line is the path
* - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
* Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN)
*
* An absolute path may stand alone or follow a marker. Any label, before an
* absolute, relative or `~/` path, must be an explicit path label
* (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic
* labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative
* path needs a path label to be taken spaced. One trailing sentence mark is left
* out, and a leading run of `./` or `../` is not a segment. The candidate must read as ONE path:
* it ends on a known extension, a rooted one has a directory before its first
* space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
* and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:` (or `,` inside parentheses), is an arrow or has
* no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and
* a run of spaces (a column gap) is refused, so two paths on a line are never
* merged. A relative candidate whose only `/` word is its last is prose after the
* label (`Wrote: 120 lines to src/foo.ts`) and is left alone, which also splits
* `Saved to: My Docs/plan.md` as master does. An earlier word that is a lowercase
* connective or verb (SPACED_PATH_PROSE_WORD: `and`, `in`, `has`, `contains`, ...)
* or a bare `=` makes the candidate a sentence (`/tmp/out has the logs in run.log`),
* so a name such as `Pros and Cons.md` splits as master does. A command line is
* refused: an executable under `bin/`, `.bin/`, `scripts/` or `tools/` as the
* first word, any earlier word ending in `$` (a shell prompt, `/home/me/proj$ cat a.md`,
* `/mnt/c/My Docs$ ls a.md`), or any `-x`/`--x` flag word.
*
* A candidate that is one path but not a linkable one (an unknown root such as
* `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last
* word still holds a `/`: its span links nothing, rather than the fragment after
* its last space. Otherwise (`/usr/bin/env node build.js`) the line is left to
* the ordinary detectors.
*
* Linear: one bounded prefix match, then string scans.
*
* @param {string} line One line, no newline.
* @param {{parenthesised?: boolean}} [options] Refuse comma-separated lists inside parentheses only.
* @returns {{path: string, index: number, link: boolean} | null}
*/
function spacedFilePathOnLine(line, options = {}) {
const text = String(line || '');
const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text);
const index = prefix[0].length;
const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, '');
if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null;
// After a label, only a path label introduces a path; with no label, only an
// absolute path may stand alone. `Error: /a/b contains c.md` is prose.
if (prefix[2] ? !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2]) : !path.startsWith('/')) return null;
if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null;
// A run of spaces is a column gap (`git status`, `ls -l`), not a name.
if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://') || path.includes(' ')) return null;
const words = path.split(' ');
// A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js`
// or `/review the changes in src/a.ts` is a command, not a spaced path.
if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null;
// A command whose executable sits under a known root (`/opt/homebrew/bin/node a.js`,
// `/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`, `/home/me/proj/scripts/deploy prod.yml`)
// is not a path either.
if (/\/(?:\.?bin|scripts?|tools)\/[^/]+$/.test(words[0]) || words.some((word) => /^-{1,2}\w/.test(word))) {
return null;
}
for (const word of words.slice(0, -1)) {
if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null;
if (options.parenthesised && word.endsWith(',')) return null;
// A shell prompt that prints its working directory in full (`/home/me/proj$ cat
// notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is
// one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$`
// can end any earlier word, not only the first.
if (word.endsWith('$')) return null;
// A word with no letter or digit (a prompt glyph `❯` or `$`, a `✓`) is never
// part of a file name, apart from a bare `-` (`OneDrive - Contoso`).
if (word !== '-' && !/[\p{L}\p{N}]/u.test(word)) return null;
// `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix.
if (/:\d/.test(word)) return null;
// An extension anywhere in an earlier word (`a.md,`, `a.ts#L12`) ends a path there.
if (PATH_TOKEN_HAS_EXTENSION.test(word)) return null;
// A connective or verb joins a path to prose (`/tmp/out has the logs in run.log`,
// `Files: src/components and utils/x.ts`), so the candidate is a sentence.
if (SPACED_PATH_PROSE_WORD.test(word)) return null;
}
if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null;
const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/');
const segments = path.replace(/^(?:~?\/|(?:\.\.?\/)+)/, '').split('/');
const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..');
// A path label vouches that a path follows, not for the words before it: when
// only the last word holds a `/` (`Wrote: 120 lines to src/foo.ts`), the spaces
// are prose, so a relative candidate is left to the ordinary detectors.
const firstSlash = words.findIndex((word) => word.includes('/'));
if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null;
// Reserve an unlinkable span only when it still reads as a path to its end;
// otherwise leave the line to the ordinary detectors.
if (!link && !words[words.length - 1].includes('/')) return null;
return { path, index, link };
}
/**
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
@@ -2272,6 +2426,31 @@ function resolveLinkedFilePath(filePath, workingDir) {
return `/${parts.join('/')}`;
}
/**
* The directory a RELATIVE path printed for a session is relative TO.
*
* `workingDir` is recorded once at create time and never follows a `cd`, so it
* stops describing where a shell (or an agent that changed directory) really
* is. `session.paneCwd` is the pane's live `#{pane_current_path}`, published by the
* server from its pane read. It only changes which BASE a relative path joins;
* the result is then routed like any other path, so a target outside the
* workspace still goes through the attachment routes.
*
* Remote and Docker sessions are excluded (the server never sends them a paneCwd,
* and this checks again): their local pane runs the ssh / docker-exec CLIENT,
* so its directory says nothing about the agent. An unknown paneCwd falls back to
* `workingDir`.
*
* @param {{workingDir?: string, paneCwd?: string, remote?: unknown, docker?: unknown}} [session]
* @returns {string|undefined}
*/
function relativeResolutionBase(session) {
if (!session) return undefined;
const paneCwd = session.paneCwd;
if (!session.remote && !session.docker && typeof paneCwd === 'string' && paneCwd.startsWith('/')) return paneCwd;
return session.workingDir;
}
/**
* Extensions the file preview renders as a PAGE, from the sandboxed
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
@@ -2344,8 +2523,16 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
const continuesPrevious = (r) => {
if (r <= 0) return false;
if (lineAt(r)?.isWrapped) return true;
// A divider row never continues the row above, even when that row reaches
// the right edge: a URL ending at the edge must not absorb `────` below it.
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(lineAt(r)?.translateToString(true) || '')) return false;
const prev = lineAt(r - 1);
return !!prev && (prev.translateToString(true) || '').length >= width;
const prevText = prev?.translateToString(true) || '';
// A full-width table divider is a row boundary, not a hard-wrapped line.
// Treating it as a continuation glues the divider onto the row below and
// hides that row's wrapped URL from the link provider.
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(prevText)) return false;
return prevText.length >= width;
};
let startRow = row;
@@ -2396,6 +2583,16 @@ const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTER
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
/**
* Lowercase connectives and verbs that put a spaced candidate in a sentence, for
* {@link spacedFilePathOnLine}. `at` stays out (`Screenshot 2026-10-24 at 10.00.00.png`)
* and so does `of` (`Copy of report.docx`); a name using a listed word
* (`Pros and Cons.md`) splits as it did before spaced paths were linked.
*/
const SPACED_PATH_PROSE_WORD =
/^(?:and|or|to|in|into|from|the|is|are|was|were|has|have|had|contains|holds|see|then|vs|via|as)$/;
/** A rooted path's start (`/home/`, `/mnt/`, …), for {@link spacedFilePathOnLine}. */
const SPACED_PATH_ROOT = new RegExp(`^\\/(?:${FILE_LINK_ROOT_GROUP})\\/`);
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
@@ -2414,9 +2611,10 @@ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0]
* top directory the broken token did (the next entry of a listing).
*/
function pathContinuation(prevText, nextText) {
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
const previous = String(prevText || '').trimEnd();
const next = String(nextText || '').replace(/^\s+/, '');
const tail = /[^\s"'<>|;&]+$/.exec(previous)?.[0] || '';
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
@@ -2443,7 +2641,9 @@ function pathContinuation(prevText, nextText) {
* A following logical line is glued on (its indent dropped, cell mapping kept
* exact) when {@link pathContinuation} says it carries the path on and the row it
* broke off is at least as long as the row it continues on. Bounded by the same
* `maxRows` as the logical line.
* `maxRows` as the logical line. Hard breaks concatenate without inserting spaces.
* An ambiguous parenthesised filename also carries `pathAlternatives` for a
* guarded lookup on activation; its text and cell mapping remain unchanged.
*
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
*/
@@ -2464,6 +2664,7 @@ function terminalPathLine(buffer, row, cols, maxRows) {
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
const extend = (first) => {
const parts = [first];
const alternatives = [];
let text = first.text;
let rows = rowsOf(first);
let confirmed = 1;
@@ -2473,25 +2674,41 @@ function terminalPathLine(buffer, row, cols, maxRows) {
const kind = continues(parts[parts.length - 1], next, text);
if (!kind) break;
parts.push(next);
if (kind === 'join') {
const opening = text.lastIndexOf('(');
const closing = next.text.indexOf(')');
if (opening >= 0 && closing > 0 && !opensBashCall(text, opening)) {
const before = text.slice(opening + 1);
const after = next.text.slice(0, closing).trimStart();
const spaced = spacedFilePathOnLine(`${before} ${after}`, { parenthesised: true });
if (!before.includes(')') && !/[/-]$/.test(before) && !after.includes('/') && spaced?.link) {
alternatives.push({ path: before.slice(spaced.index) + after, spacedPath: spaced.path });
}
}
}
text += next.text.replace(/^\s+/, '');
rows += rowsOf(next);
if (kind === 'join') confirmed = parts.length;
}
return parts.slice(0, confirmed);
return { parts: parts.slice(0, confirmed), alternatives };
};
// The hovered row may be the second or third line of a broken path: walk back
// to where it could start, then let the forward pass decide what really joins.
// A middle fragment may have no slash, so only a forward pass from its
// rooted start can confirm a path spanning three or more rows.
let extended = extend(base);
let first = base;
let rows = rowsOf(base);
while (first.startRow > 0) {
const prev = lineAt(first.startRow - 1);
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
if (!prev || rows + rowsOf(prev) > bound) break;
first = prev;
rows += rowsOf(prev);
const tail = /[^\s"'<>|;&]+$/.exec(first.text.trimEnd())?.[0] || '';
if (!tail.includes('/')) continue;
const candidate = extend(first);
if (candidate.parts[candidate.parts.length - 1].endRow >= base.startRow) extended = candidate;
}
let parts = extend(first);
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
const { parts, alternatives } = extended;
if (parts.length === 1) return parts[0];
const segments = [];
@@ -2516,7 +2733,410 @@ function terminalPathLine(buffer, row, cols, maxRows) {
}
return -1;
};
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
return {
startRow: parts[0].startRow,
endRow: parts[parts.length - 1].endRow,
text,
offsetToCell,
cellToOffset,
...(alternatives.length ? { pathAlternatives: alternatives } : {}),
};
}
/**
* Web URLs in terminal text, with the trimming the link provider applies.
*
* A single `&` is part of the URL (query separator); `&&` is the shell operator
* and ends it. `)` is kept INSIDE a URL when it closes a `(` that is also inside
* it (`…/Team-Space(QA)Archive/…` used to link only as far as `(QA`), and an
* unmatched `)` ends the URL, which keeps the closing paren of `(https://…)` and
* `[text](https://…)` out of the link. Trailing sentence punctuation is trimmed.
*
* @param {string} text
* @returns {Array<{url: string, startIndex: number, endIndex: number, rawEnd: number}>}
* `endIndex` is exclusive; `rawEnd` is where the match ran to before the
* paren/punctuation trim (the wrap-continuation test needs it).
*/
function findTerminalUrls(text) {
// Named `urlPattern` on purpose: test/link-provider-regex.test.ts extracts it
// by name to prove it stays linear-time on hostile lines.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&\]\x00-\x1f]|&(?!&))+/g;
const urls = [];
let match;
while ((match = urlPattern.exec(String(text || ''))) !== null) {
const raw = match[0];
let end = raw.length;
let depth = 0;
for (let i = 0; i < raw.length; i++) {
if (raw[i] === '(') depth++;
else if (raw[i] === ')') {
if (depth === 0) {
end = i;
break;
}
depth--;
}
}
const url = raw.slice(0, end).replace(/[.,;:!?&]+$/, '');
if (!/:\/\/[^/]/.test(url)) continue;
urls.push({ url, startIndex: match.index, endIndex: match.index + url.length, rawEnd: match.index + raw.length });
}
return urls;
}
/**
* The row span a URL covers when the CLI wrapped it with its OWN newlines at a
* point short of the terminal width.
*
* {@link terminalLogicalLine} joins soft wraps and full-width hard wraps. A CLI
* that word-wraps inside a table cell or a paragraph (codex's markdown tables,
* for one) breaks a long URL at `/`, `?`, `-` … well before the right edge and
* indents the rest to the cell's column, so neither rule fires and each row
* linked a fragment. A row continues the URL above it only when ALL hold:
* - the URL fragment ends on a character a wrapper breaks after
* (`/ ? & = - _ % # + ~`); a following table column may be excluded;
* - the next row is indented, but no further right than the column the URL
* started in (a table cell or list item continuation, not unrelated text),
* and no column gap lies between its indent and the URL on the first row
* (that would be the next record of a table);
* - that row's first token looks like URL material (contains one of
* `% & = / ? . - _`), is not a bare number/version or list marker,
* and is not itself a new `scheme://`, a shell prompt (`user@host:`),
* a home path (`~/`), an email address, an HTTP status line, a label
* ending in `:`, a measurement or clock time (`45%`, `12.3ms`,
* `10:00:00`), or a `name=value` field under a URL ending in `/`;
* without `% & = / ?`, the token must finish its cell (row end or a
* gap of at least two spaces) and must not be code with a call paren
* or a word ending a sentence (`Done.`).
* The last rule is what stops prose such as "…/files/" + "then run it" from
* being glued into one link.
*
* Bounded by `maxRows`, like the logical line. Pure: reads rows only.
*
* @param {{getLine: (row: number) => any, length: number}} buffer
* @param {number} row 0-based absolute row being examined.
* @param {number} cols Terminal width.
* @param {number} [maxRows]
* @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null}
* `firstRowUrlEnd` is where the URL's cell ends on the first row when
* other table columns follow it and that row does not soft-wrap; null
* means the row is not part of a URL continued this way.
*/
function terminalUrlContinuationExtent(buffer, row, cols, maxRows) {
const bound = Math.max(1, maxRows || 12);
const here = terminalLogicalLine(buffer, row, cols, bound);
if (!here) return null;
const chainFrom = (start) => {
const first = terminalLogicalLine(buffer, start, cols, bound);
if (!first || first.startRow !== start) return null;
let text = first.text.replace(/\s+$/, '');
let endRow = first.endRow;
let urlStartCol = -1;
let firstRowUrlEnd = null;
let extended = false;
while (endRow - start + 1 < bound) {
const urls = findTerminalUrls(text);
const last = urls[urls.length - 1];
// Only on a one-row first line: firstRowUrlEnd cuts the first PHYSICAL
// row (terminalLinkLine), so a column sitting on the soft-wrapped part of
// the URL's row would stay in the text and take the next row with it.
const trailingColumns =
first.startRow === first.endRow &&
endRow === first.endRow &&
last &&
last.rawEnd < text.length &&
/^ {2,}\S/.test(text.slice(last.rawEnd));
const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text;
if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break;
if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col;
const next = buffer.getLine(endRow + 1);
if (!next || next.isWrapped) break;
const nextText = next.translateToString(true) || '';
const indent = nextText.length - nextText.replace(/^\s+/, '').length;
const token = nextText.slice(indent).split(/\s/, 1)[0];
// A hard continuation is indented (a table cell, a list item, Claude's
// two-space paragraph indent); a column-0 row is a new line of output.
if (!token || !indent || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent)))
break;
// When other table columns follow the URL, the next row must align with
// the start of its cell. A new numbered row must not become URL text.
if (
trailingColumns &&
(!indent ||
first.text[indent - 1] !== ' ' ||
first.text[indent] === ' ' ||
/ {2,}/.test(first.text.slice(indent, last.startIndex)))
)
break;
// The next row must start inside the URL's own cell: a column gap between
// its indent and the URL on the first row means it opens an earlier
// column (the next record of a table), not the rest of the URL.
if (/ {2,}/.test(first.text.slice(indent, last.startIndex).trimStart())) break;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break;
// A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line
// are never the rest of a URL, however much URL punctuation they carry.
if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break;
// Nor is a token that is a whole email address.
if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break;
if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break;
// A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`);
// the rest of a URL does not.
if (token.endsWith(':')) break;
// After a `/`, a `name=value` token is the next assignment or log field
// (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`.
if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break;
// Nor is a measurement or a clock time: `45%` and `45%|####` progress,
// `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding).
if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break;
// Hyphens, underscores and dots also occur in prose. Without stronger
// URL punctuation, the token must finish its cell, not introduce a sentence.
const rest = nextText.slice(indent + token.length);
if (!/[%&=/?]/.test(token)) {
// Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a
// word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title
// (`Mercury_(planet)`) and a file name before its period
// (`install.html.`) still continue the URL.
if (/[^_]\(/.test(token)) break;
if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break;
if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break;
}
const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound);
if (!nextLogical) break;
if (trailingColumns) firstRowUrlEnd = last.rawEnd;
text = urlText + nextLogical.text.slice(indent).replace(/\s+$/, '');
endRow = nextLogical.endRow;
extended = true;
}
return { startRow: start, endRow, extended, firstRowUrlEnd };
};
// Walk up through earlier logical lines: the nearest one whose URL chain
// reaches this row owns it.
let start = here.startRow;
while (start >= 0 && here.startRow - start < bound) {
const chain = chainFrom(start);
if (chain && chain.extended && chain.endRow >= here.startRow) {
return {
startRow: chain.startRow,
endRow: chain.endRow,
...(chain.firstRowUrlEnd === null ? {} : { firstRowUrlEnd: chain.firstRowUrlEnd }),
};
}
if (start === 0) break;
const above = terminalLogicalLine(buffer, start - 1, cols, bound);
if (!above) break;
start = above.startRow;
}
return null;
}
/**
* The text the link provider scans for `row`: {@link terminalPathLine}, widened
* to the rows of a URL the CLI wrapped short of the edge
* ({@link terminalUrlContinuationExtent}). Same shape as terminalPathLine.
*
* Over a widened span every row is read trimmed (except one a soft wrap
* continues from, which fills the width), a HARD continuation's indent is
* dropped (the cell mapping keeps the cells), and the first row stops at the
* URL's cell when other table columns follow it, so those columns never sit
* between the URL's pieces. Those columns still link: they are moved after the
* stitched rows as a segment of their own, mapped back to their cells.
*
* @returns Same shape as {@link terminalPathLine}; null when the row does not exist.
*/
function terminalLinkLine(buffer, row, cols, maxRows) {
const base = terminalPathLine(buffer, row, cols, maxRows);
if (!base) return null;
const extent = terminalUrlContinuationExtent(buffer, row, cols, maxRows);
if (!extent || (extent.startRow >= base.startRow && extent.endRow <= base.endRow)) return base;
const startRow = Math.min(extent.startRow, base.startRow);
const endRow = Math.max(extent.endRow, base.endRow);
const segments = [];
let text = '';
let columnsAfterUrl = '';
for (let r = startRow; r <= endRow; r++) {
const line = buffer.getLine(r);
if (!line) break;
const next = r < endRow ? buffer.getLine(r + 1) : null;
let rowText = line.translateToString(!(next && next.isWrapped)) || '';
if (r === extent.startRow && extent.firstRowUrlEnd != null) {
columnsAfterUrl = rowText.slice(extent.firstRowUrlEnd);
rowText = rowText.slice(0, extent.firstRowUrlEnd);
}
let colStart = 0;
if (r > startRow && !line.isWrapped) {
colStart = rowText.length - rowText.replace(/^\s+/, '').length;
rowText = rowText.slice(colStart);
}
segments.push({ row: r, textStart: text.length, colStart });
text += rowText;
}
const lastRow = segments[segments.length - 1].row;
// The columns cut off the URL's first row (a path or date column of a table)
// are scanned after the stitched rows, so they keep their links on that row.
// They open with the cell gap, which keeps them apart from the URL; the one
// space before them belongs to the last row, so an end offset there still
// maps onto that row and never onto their first cell.
if (columnsAfterUrl.trim()) {
text += ' ';
segments.push({ row: extent.startRow, textStart: text.length, colStart: extent.firstRowUrlEnd });
text += columnsAfterUrl;
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
}
return { row: startRow, col: offset };
};
// The cut row has two segments: the one starting at or left of the cell owns it.
const cellToOffset = (targetRow, targetCol) => {
let seg = null;
for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s;
return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
};
return {
startRow,
endRow: lastRow,
text,
offsetToCell,
cellToOffset,
...(base.pathAlternatives ? { pathAlternatives: base.pathAlternatives } : {}),
};
}
/**
* Absolute file paths the CLI word-wrapped INSIDE a table cell (codex markdown
* tables), returned as one link per path with a segment per row.
*
* Before screenshot (/tmp/x/docs/review/ After screenshot (/tmp/x/review/BLDMESH-
* BLDMESH-243/before-overview.png) 243/after-overview.png)
*
* Neither fragment matches FILE_PATH_LINK_PATTERN alone (the first has no
* extension, the second no absolute root), and a linear stitch of the rows
* cannot serve a two-column table: each column's continuation sits in the same
* next row. So each fragment is followed DOWN ITS OWN CELL: the cell's left
* edge is where its text starts after the previous ≥2-space column gap, and the
* continuation is the token starting at exactly that column on the next row.
* A fragment qualifies only when it is the last thing in its cell on that row
* (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places
* a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over
* `config.yaml`) is a whole value, never the first piece of the next row's. A
* continuation row must be blank to the left of the cell (a wrapper wraps every
* column of one table row together), unless a path in a left column continued
* on it too: a label there (`Entry`, `│ Log │`) is the next record. Every
* continuation must pass pathContinuation and its cell must be no wider than the
* preceding cell's text, so independent listing rows and longer prose never
* displace links.
*
* Pure: reads rows only. Cells are taken one per string character (xterm rows
* with wide glyphs would shift columns; such rows simply fail the alignment).
*
* @param {{getLine: (row: number) => any, length: number}} buffer
* @param {number} row 0-based absolute row being examined.
* @param {number} [maxRows] Bound on rows one path may span.
* @returns {Array<{path: string, segments: Array<{row: number, startCol: number, endCol: number}>}>}
* Paths with a segment on `row`; `endCol` is exclusive, columns 0-based.
*/
function terminalCellWrappedPaths(buffer, row, maxRows) {
const bound = Math.max(2, maxRows || 4);
const rootPattern = new RegExp(`\\/(?:${FILE_LINK_ROOT_GROUP})\\/`, 'g');
// Box-drawing verticals are cell borders (Claude Code draws tables with them).
const isPathChar = (ch) => !!ch && !/[\s"'<>|;&\x00-\x1f│┃║]/.test(ch);
const rowText = (r) => {
const line = buffer.getLine(r);
return line ? (line.translateToString(true) || '').replace(/\s+$/, '') : null;
};
// A fragment is the last text in its cell on this row when the row ends, a
// column gap (≥2 spaces) follows it, or a cell border does.
const endsCell = (text, end) => end >= text.length || /^(?: {2,}| *[│┃║|])/.test(text.slice(end));
const fromStart = (startRow) => {
const found = [];
const text = rowText(startRow);
if (!text) return found;
rootPattern.lastIndex = 0;
let m;
while ((m = rootPattern.exec(text)) !== null) {
const start = m.index;
if (start > 0 && !/[\s(\[{"'`<]/.test(text[start - 1])) continue;
let end = start;
while (end < text.length && isPathChar(text[end])) end++;
rootPattern.lastIndex = Math.max(end, start + 1);
if (!endsCell(text, end)) continue;
// Already a complete path on its own row: the ordinary detector owns it.
const alone = absoluteFilePathPattern().exec(text.slice(start, end));
if (alone && alone.index === 0 && alone[0].length === end - start) continue;
// The cell's left edge: just past the nearest ≥2-space gap or cell border
// (and the padding after it) before the path.
const before = text.slice(0, start);
let cellCol = before.length - before.replace(/^\s+/, '').length;
const edge = /(?: {2,}|[│┃║|] *)(?=\S)/g;
let e;
const upToPath = text.slice(0, start + 1);
while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length;
let joined = text.slice(start, end);
let previousCellLength = end - cellCol;
const segments = [{ row: startRow, startCol: start, endCol: end }];
for (let r = startRow + 1; r < startRow + bound; r++) {
const line = buffer.getLine(r);
if (!line || line.isWrapped) break;
const next = rowText(r);
if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break;
let tokenEnd = cellCol;
while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++;
const token = next.slice(cellCol, tokenEnd);
// Include prose after the token in this cell, but not another column.
const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd();
// A wrapper breaks a path after `/` or `-`; a fragment that ends inside a
// name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece.
// codex wraps table cells on Unicode line-break opportunities plus a
// hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14
// class AL).
if (!/[/-]$/.test(joined)) break;
// A wrapper wraps every column of one table row together, so a row that
// carries a cell's continuation is blank to its left unless a path in a
// left column continued on that row too. A label there (`Entry`,
// `│ Log │`) opens the next record, even under a directory ending in `/`
// (`Workspace /home/u/proj/app/` over `Entry main.py`). A left column of
// wrapped prose (a `Description | Path` table) fills that row as well, so
// the path beside it is not joined and keeps its per-row fragment links:
// the safe direction.
// Known trade-off: with nothing to the left, a directory written with its
// trailing slash over a relative path in the same column (`/home/u/proj/`
// over `index.ts`) still joins, since it reads exactly like a wrap at `/`.
if (
/[^\s│┃║|]/.test(next.slice(0, cellCol)) &&
!found.some((hit) => hit.segments.some((seg) => seg.row === r && seg.endCol <= cellCol))
) {
break;
}
if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break;
const candidate = joined + token;
const match = absoluteFilePathPattern().exec(candidate);
if (match && match.index === 0 && match[0].length > joined.length) {
segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length });
found.push({ path: match[0], segments });
break;
}
if (!endsCell(next, tokenEnd)) break;
joined = candidate;
previousCellLength = cellText.length;
segments.push({ row: r, startCol: cellCol, endCol: tokenEnd });
}
}
return found;
};
const results = [];
for (let s = row; s >= 0 && row - s < bound; s--) {
for (const hit of fromStart(s)) {
if (hit.segments.some((seg) => seg.row === row)) results.push(hit);
}
}
return results;
}
// ═══════════════════════════════════════════════════════════════
@@ -3349,7 +3969,9 @@ function clampTerminalDimensions(proposed) {
* could reach it. Output visible, typing invisible, for as long as the claim
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
* decide how much is on screen at once, and keeping the local row count keeps
* the composer at the bottom of a viewport that scrolls.
* the composer at the bottom of a viewport that scrolls. The one deliberate
* screen taller than its box is this device's own rows held under the
* on-screen keyboard, which keyboardClipPx keeps the composer in view for.
*
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
* @param {{cols: number, rows: number}|null} pty - what the server just reported
@@ -3361,6 +3983,62 @@ function reconcilePtyGeometry(local, pty) {
return { adopt: true, cols: pty.cols };
}
/** Rows below the last content within which the cursor still counts (keyboardClipPx). */
const KEYBOARD_CLIP_CURSOR_REACH = 2;
/**
* How far to scroll the terminal SCREEN up while its rows are held under the
* on-screen keyboard (setting `keyboardHoldsRows`).
*
* Every PTY height change makes Claude Code clear the screen and reprint only
* the transcript tail that fits, so rows above that tail are lost from tmux and
* older ones stay in history as stale copies. The keyboard opening and closing
* was such a change each time. Instead the rows stay as they were and
* `.xterm-screen` (taller than its box now) is translated up by this many px.
*
* This is the one deliberate exception to "a screen taller than its container
* hides the input line" (see reconcilePtyGeometry): the offset below is what
* keeps the live content in view.
*
* - Following live output: the last row with content sits at the bottom of the
* box. A fresh shell prompting on row 1 shows the top; Claude's footer under
* its composer stays visible. The cursor counts only when it is right below
* that content (a prompt or composer line still empty): a TUI that parks its
* cursor far below what it drew (codex's dialogs, with the cursor on the last
* row of an otherwise blank screen) must not scroll its content out of view.
* - Reading history: the offset follows the scroll position, so the top of the
* scrollback is reachable and scrolling stays continuous.
*
* Recomputed on every render with no hysteresis; add some if a TUI's mid-redraw
* frames ever make the view jitter.
*
* @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number,
* lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input
* lastContentRow: the last non-blank screen row, or -1 when the screen is blank
* @returns {number} px, between 0 and the screen's overflow
*/
function keyboardClipPx(input) {
const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input;
if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0;
const max = Math.max(0, rows * cellHeight - viewHeight);
if (max === 0) return 0;
if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY)));
const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH;
const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow;
const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight;
return Math.round(Math.min(max, Math.max(0, bottom - viewHeight)));
}
/**
* How many screen rows, from the top, are fully visible at a given offset: the
* local-echo overlay grows upward from the last of them (`setRowLimit`).
* @returns {number}
*/
function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) {
if (!(cellHeight > 0)) return 0;
return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight));
}
/**
* Which session does a dashboard URL's fragment ask for? Another page that
* holds the dashboard's window, such as a task board, points it at
@@ -3435,12 +4113,20 @@ if (typeof window !== 'undefined') {
absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
relativeResolutionBase,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanTerminalLines = {
terminalLogicalLine,
terminalPathLine,
terminalLinkLine,
terminalUrlContinuationExtent,
findTerminalUrls,
terminalCellWrappedPaths,
};
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
clampDividerPercent,
@@ -3491,6 +4177,8 @@ if (typeof window !== 'undefined') {
window.CodemanTerminalGeometry = {
clampTerminalDimensions,
reconcilePtyGeometry,
keyboardClipPx,
keyboardVisibleRowLimit,
TERMINAL_MIN_COLS,
TERMINAL_MIN_ROWS,
};
+630
View File
@@ -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;
},
});
+88 -5
View File
@@ -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) {
+101 -5
View File
@@ -107,6 +107,11 @@
'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': '父会话已关闭;单独放置',
@@ -351,6 +356,7 @@
Delete: '删除',
Remove: '移除',
Edit: '编辑',
Fetch: '拉取',
Refresh: '刷新',
Back: '返回',
Next: '下一步',
@@ -483,6 +489,9 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(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 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
@@ -519,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 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
@@ -534,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': '项目洞察',
@@ -562,7 +612,6 @@
Send: '发送',
Permission: '权限',
Question: '问题',
Idle: '空闲',
'Read My Mind': '读心术',
'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示',
'Predict my next prompt': '预测我的下一条提示',
@@ -599,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)',
@@ -608,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': '仅活动标签页',
@@ -667,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)': '手势控制(测试版)',
@@ -697,6 +758,9 @@
'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 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。',
@@ -832,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': '+ 新建任务',
@@ -877,6 +959,8 @@
'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': '推送通知已启用',
@@ -1339,6 +1423,7 @@
[/^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}`],
@@ -1407,6 +1492,17 @@
(_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} 正在终端中等待你的回答`],
+18 -9
View File
@@ -193,7 +193,10 @@ Object.assign(CodemanApp.prototype, {
// 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;
@@ -244,14 +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.
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~`);
}
});
+239 -2
View File
@@ -216,6 +216,13 @@
<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>
@@ -625,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">&#x21BB;</button>
<button class="btn-icon-sm" id="gitStatusFetchBtn" onclick="app.fetchGitStatusNow()" title="Fetch" aria-label="Fetch git status">&#x2913;</button>
<button class="btn-icon-sm" onclick="app.closeGitStatusPanel()" title="Close" aria-label="Close git status">&times;</button>
</div>
</div>
@@ -632,6 +640,22 @@
<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">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">&times;</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">
@@ -1032,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>
@@ -1331,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>
@@ -1719,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 &rarr; 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>
@@ -1944,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>
@@ -2128,6 +2228,7 @@
<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>
@@ -2178,6 +2279,13 @@
</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>
@@ -2196,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>
@@ -2221,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>
@@ -2413,6 +2535,13 @@
</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>
@@ -2700,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">
@@ -3143,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 &quot;When a tab is deleted&quot; 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 ════════════════════════════════════════════════════ -->
@@ -3563,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/&lt;name&gt;, so deleting the case deletes this working tree.</span>
<span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/&lt;name&gt;, 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&hellip;</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>
@@ -4429,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>
+77 -2
View File
@@ -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>+&#x1F4C1;</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">&#x21BB;</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';
@@ -1246,7 +1317,11 @@ const KeyboardAccessoryBar = {
// the single-line input transport and is what terminal.paste() emitted.
// Paste markers inside the text are dropped first: a clipboard carrying
// `ESC[201~` would end the bracket early and run the rest without Enter.
const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
// 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?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return false;
+22
View File
@@ -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) {
+69 -1
View File
@@ -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. */
@@ -949,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;
@@ -3427,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;
@@ -3449,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) {
@@ -4127,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;
+13 -9
View File
@@ -643,9 +643,9 @@ Object.assign(CodemanApp.prototype, {
/**
* Left/Right change the agent only while the new-session row is highlighted,
* and only when the key would not move the caret anyway (Left at the start of
* the query, Right at its end, no selection), so editing a typed case name
* keeps working.
* and the caret is at either query boundary, with no selection. Both arrows
* stay in picker navigation after typing a case name; inside the query they
* still edit text, as do modified arrows and selection changes.
*/
_commandPaletteArrowCyclesAgent(e) {
const active = (this.commandPaletteItems || [])[this.commandPaletteActiveIndex || 0];
@@ -657,7 +657,7 @@ Object.assign(CodemanApp.prototype, {
const start = Number.isFinite(input?.selectionStart) ? input.selectionStart : value.length;
const end = Number.isFinite(input?.selectionEnd) ? input.selectionEnd : start;
if (start !== end) return false;
return e.key === 'ArrowRight' ? end >= value.length : start <= 0;
return start <= 0 || end >= value.length;
},
_findCommandPaletteCaseMatch(query = '') {
@@ -4195,7 +4195,7 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Register an out-of-workspace path as a live external attachment and return
* Register a file path as a live external attachment and return
* its id, so the preview can render it through the by-id attachment routes.
*
* `notify: false` keeps this quiet: the caller is already opening the file in
@@ -4204,7 +4204,7 @@ Object.assign(CodemanApp.prototype, {
* full attachment guard (blocked secret trees, extension allowlist, symlinks
* resolved), so a refusal here is a policy answer worth showing verbatim.
*
* @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
* @returns {Promise<{attachmentId?: string, size?: number, error?: string, status?: number}>}
*/
async _registerExternalPreview(filePath, sessionId) {
try {
@@ -4220,14 +4220,18 @@ Object.assign(CodemanApp.prototype, {
const reason = result?.error || `Cannot open this file (HTTP ${res.status})`;
// The registry's type answer is a policy term, not an explanation, and the
// user just clicked a file they can see on disk. Say what IS previewable
// from outside the workspace instead.
// through this lookup instead, using the path's actual workspace scope.
if (/unsupported/i.test(reason)) {
const ext = (filePath.split('.').pop() || '').toLowerCase();
const target = this._isExternalPreviewPath(filePath, sessionId)
? `.${ext} from outside the session workspace`
: `"${filePath}" through the attachment lookup`;
return {
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
status: res.status,
error: `Cannot preview ${target} (images, video, audio, PDF, Office documents and text files only).`,
};
}
return { error: reason };
return { error: reason, status: res.status };
} catch (err) {
return { error: err.message || 'Cannot open this file' };
}
+2 -2
View File
@@ -601,13 +601,13 @@ Object.assign(CodemanApp.prototype, {
// 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.
// 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._noteSettingsWriteRefusal?.(res))
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
.catch(() => {});
},
+1 -21
View File
@@ -1,21 +1 @@
{
"notices": [
{
"id": "opencode-console-breach-2026-10",
"severity": "warning",
"cli": "opencode",
"published": "2026-10-17",
"expires": "2026-12-31",
"title": "OpenCode Console breach: check your API keys",
"message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.",
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
"i18n": {
"zh-CN": {
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
"message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。",
"label": "查看 OpenCode 公告"
}
}
}
]
}
{ "notices": [] }
+373 -52
View File
@@ -684,9 +684,12 @@ Object.assign(CodemanApp.prototype, {
/**
* Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity).
* `options` comes from the tab group menu's "New session": `count` overrides
* the instance steppers for this launch only, `tabGroupId` rides every create
* request so the server places the session in that group.
* `options` comes from the tab group menu's "New session", `_launchOnce` (a
* worktree, a tab's "+"): `count` overrides the instance steppers for this
* launch only, `tabGroupId` rides every create request so the server places the
* session in that group, `caseName` launches in that case instead of the
* toolbar's selected one, and `mode` starts that CLI instead of the Run
* button's mode, which is never touched.
*/
async run(options = {}) {
if (this._runInFlight) return;
@@ -701,7 +704,7 @@ Object.assign(CodemanApp.prototype, {
}
try {
const mode = this._runMode || 'claude';
const mode = options.mode || this._runMode || 'claude';
if (mode === 'shell') {
return await this.runShell(options);
}
@@ -732,12 +735,12 @@ Object.assign(CodemanApp.prototype, {
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
// on this device and wins 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 (_noteSettingsWriteRefusal).
// read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('runMode', mode);
} else {
this._apiPut('/api/settings', { runMode: mode })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.then((res) => this._noteSettingsWriteResult?.(res, 'Run mode'))
.catch(() => {});
}
// Close menu
@@ -820,7 +823,8 @@ Object.assign(CodemanApp.prototype, {
* never gated; both callers skip it before asking.
*
* Reads only what is already known and never starts a container probe: the
* Run menu starts one (_probeDockerCaseModes), and its answer lands in
* Run menu and a tab's "+" (openTabNewSessionMenu, app.js) start one
* (_probeDockerCaseModes), and its answer lands in
* _dockerCaseModes, which this reads. Until then (a probe still in flight)
* the stored availableModes decide, else nothing is gated.
*/
@@ -998,12 +1002,23 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Best-effort lookup of the model llama-swap currently has loaded and ready on this
* endpoint, so the picker can offer it first instead of making the user remember what
* they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own
* `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an
* unreachable endpoint, or a loaded model this host no longer lists as discovered —
* never throws, since a failed probe should just skip promotion, not break the picker.
* Best-effort lookup of what the endpoint itself says it has loaded and ready, so the
* picker can offer it first instead of making the user remember what they picked last
* time it mattered. Only llama-swap answers this question (the route reads its
* `/running`); every other OpenAI-compatible server (a bare llama.cpp, Ollama, vLLM,
* or a gateway such as Bifrost fronting a whole fleet) replies `isLlamaSwap: false`,
* which is why `_getCustomModelInUseBySession` exists. `state === 'ready'` mirrors
* `_watchLlamaSwapLoading`'s own check.
*
* Resolves the raw route payload rather than a model id so the caller can tell the
* three answers apart: llama-swap naming a model; `isLlamaSwap: false`, which is what
* the route answers for EVERY failure it catches (a server without a `/running`
* route, a refused or unresolvable endpoint, a 401 from a wrong key): safe to
* explain in the UI as long as the wording also fits an endpoint that is down; and
* `null`, only when Codeman's own request to the route failed or the client-side
* bound below won the race, which stays silent, since the endpoint may merely have
* been asleep. Never throws: a failed probe should
* just skip promotion, not break the picker.
*
* ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the
* route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) —
@@ -1020,17 +1035,58 @@ Object.assign(CodemanApp.prototype, {
* `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without
* actually waiting on it. Real callers never pass it.
*/
async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) {
async _getCustomModelRunningStatus(host, timeoutMs = 800) {
const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch(
() => null
);
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
const status = await Promise.race([probe, timeout]);
return Promise.race([probe, timeout]);
},
/**
* The model llama-swap reports `ready` on this endpoint AND that this host still lists
* as discovered (an undropped-but-undiscovered model has no row to promote), else null.
*/
_pickReadyRunningModel(status, host) {
if (!status?.isLlamaSwap) return null;
const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model));
return ready?.model || null;
},
/**
* The model a LIVE session of this instance is pointed at on `host`, or null. It is the
* fallback for every endpoint that cannot answer "what is loaded?" about itself.
* A session already running on the endpoint is first-hand evidence of a model in use
* there: Codeman itself injected that endpoint and model into it, so it earns the same
* promotion llama-swap's own answer gets, under its own honest tag.
*
* ⚠️ Reads ONLY `customModel` (what Codeman injected), never `displayModel`: that field
* is display text scraped off a CLI's footer or statusline for a session header (pane
* derived, `source: 'screen'`), and promoting a row because a terminal printed a
* similar string is worse than showing nothing.
* ⚠️ Requires a live pane: `status: 'stopped'`, a null `pid` (the PTY exited; the
* same live-pane test keep-warm applies, `src/prompt-cache-keepwarm.ts`) or a recorded
* `paneExit` means nothing is on the endpoint any more, and a dead session's stale pin
* is exactly what an "in use" claim must not resurrect. `session:updated` keeps it
* current, so a session cleared back to the CLI's own backend or moved to another
* endpoint stops counting.
* ⚠️ A since-deleted endpoint simply matches no host, so no extra liveness check is
* needed here.
*/
_getCustomModelInUseBySession(host) {
const models = host?.models || [];
if (models.length === 0) return null;
let newest = null;
for (const [, session] of this.sessions || []) {
if (!session || session.status === 'stopped' || session.pid === null || session.paneExit) continue;
const pinned = session.customModel;
if (!pinned || pinned.endpointId !== host.id) continue;
if (!models.includes(pinned.modelId)) continue; // undiscovered since: no row to promote
if (!newest || (newest.createdAt || 0) <= (session.createdAt || 0)) newest = session;
}
return newest?.customModel?.modelId || null;
},
/**
* Renders the "which model" picker for a (harness, endpoint) pair with more than one
* discovered model. Async since it now awaits the currently-loaded-model probe below,
@@ -1051,15 +1107,17 @@ Object.assign(CodemanApp.prototype, {
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
const isCurrent = () => this._customModelPickGeneration === generation;
// Whichever model llama-swap actually has loaded right now beats a merely
// remembered choice — it's what a launch would attach to with zero wait, while
// "last used" might have been swapped out by another session since. Neither
// reorders past the top: exactly one model is promoted, everything else keeps
// its discovery order.
const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host);
// Three kinds of knowledge, best first: what llama-swap says it has loaded right
// now (zero wait to attach to), what a live session of this instance is pointed at
// on that endpoint (the endpoint itself cannot say, but Codeman put it there), and
// only then this device's memory of the last launch. One wins, and it never reorders
// anything else: every other row keeps its discovery order.
const runningStatus = await this._getCustomModelRunningStatus(host);
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
const promoted = currentlyLoaded || lastUsed;
const currentlyLoaded = this._pickReadyRunningModel(runningStatus, host);
const inUse = currentlyLoaded ? null : this._getCustomModelInUseBySession(host);
const lastUsed = currentlyLoaded || inUse ? null : this._getCustomModelLastUsed(mode, host.id);
const promoted = currentlyLoaded || inUse || lastUsed;
const models = [...(host.models || [])];
if (promoted && models.includes(promoted)) {
models.splice(models.indexOf(promoted), 1);
@@ -1074,23 +1132,91 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
document.getElementById('customModelPickHint').textContent =
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
// A plain static line (its markup lives in index.html), shown when NOTHING is known
// about any row: 40 alphabetical rows with no mark on any of them otherwise read as
// the app ignoring the question. `isLlamaSwap: false` is what the route answers for
// every failure it catches: a server that cannot report AND a down or refused one
// alike (`getLlamaSwapStatus`, custom-model-routes.ts), so the line's wording
// promises only that nothing was reported, which stays true either way. A probe
// that never got an answer at all (`runningStatus === null`: Codeman's own request
// failed or the 800ms bound won) keeps the line hidden, the box may merely be
// asleep.
// A remembered id that has since vanished from host.models is no promotion: count
// only a promoted id that is actually a row, or a stale "last used" would both skip
// tagging any row and hide this line, leaving 40 unmarked rows with no explanation.
const nothingKnown = !(promoted && models.includes(promoted)) && runningStatus?.isLlamaSwap === false;
const unknownLine = document.getElementById('customModelPickUnknown');
if (unknownLine) unknownLine.hidden = !nothingKnown;
list.innerHTML = models
.map((m) => {
// Two independent tags, never one exclusive slot: the promotion tag says what
// llama-swap (or this device's history) knows about the model, the Default pill
// says what the saved endpoint says about it, and on a single-purpose GPU box the
// promoted model IS the default more often than not. One slot holding whichever
// applied first silently dropped the Default marking for exactly that row.
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
// Two independent tags, never one exclusive slot: the promotion tag says what the
// endpoint, a live session, or this device's history knows about the model, the
// Default pill says what the saved endpoint says about it, and on a
// single-purpose GPU box the promoted model IS the default more often than not.
// One slot holding whichever applied first silently dropped the Default marking
// for exactly that row.
const promotion =
m === currentlyLoaded
? 'Currently loaded'
: m === inUse
? 'In use by a session'
: m === lastUsed
? 'Last used'
: null;
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
const arg = escapeHtml(JSON.stringify(m));
// data-model-id is the filter's haystack (escapeHtml covers `"` as well as
// `<>&`, so a hostile model id cannot break out of the attribute); the
// filter reads it back through dataset, which decodes it.
return `
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
<button class="run-mode-option" data-model-id="${escapeHtml(m)}" onclick="app.chooseCustomModelAndRun(${arg})">
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
</button>`;
})
.join('');
modal.classList.add('active');
// A fresh open always starts unfiltered with the caret in the search box: the
// list was just rebuilt, so clearing the input is the whole reset; focus is the
// user's next keystroke away. Inside the generation guard above, so a
// superseded probe can neither focus nor clear a modal that now shows another
// endpoint's models.
const search = document.getElementById('customModelPickSearch');
if (search) {
search.value = '';
// readmymind-ui.js's rule: on touch devices never focus an input unprompted, or
// the OS keyboard pops over the very list the picker just opened to show. Desktop
// keeps the caret in the box, one keystroke from filtering.
if (!(typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice())) {
search.focus();
}
}
this.filterCustomModelPick('');
},
/**
* View filter for the open "Choose a model" picker: case-insensitive substring
* over each row's model id, driven from the search input's own `oninput`.
* Deliberately toggles `hidden` on the already-rendered buttons rather than
* re-rendering from a second copy of the model list: the loaded/last-used
* promotion, the Default pill, the escaped inline click argument and the async
* generation guard all then stay exactly where they are, and clearing the
* query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own
* `display: flex` (styles.css, restated in mobile.css), which outranks the UA
* `[hidden]` rule, so styles.css re-asserts `display: none !important` for
* these rows: without it the filter would silently do nothing.
*/
filterCustomModelPick(value) {
const list = document.getElementById('customModelPickList');
if (!list) return;
const query = String(value || '').trim().toLowerCase();
let visible = 0;
for (const btn of list.querySelectorAll('button[data-model-id]')) {
const match = !query || btn.dataset.modelId.toLowerCase().includes(query);
btn.hidden = !match;
if (match) visible += 1;
}
const noMatches = document.getElementById('customModelPickNoMatches');
if (noMatches) noMatches.hidden = visible > 0;
},
closeCustomModelPickModal() {
@@ -2014,11 +2140,28 @@ Object.assign(CodemanApp.prototype, {
}
},
/** The case a launch runs in: its worktree's, the one it names (a tab's "+"), else the toolbar's. */
_runCaseName(options) {
if (options.worktree) return options.worktree.caseName;
return options.caseName || document.getElementById('quickStartCase').value || 'testcase';
},
/**
* Re-reads the case list after a launch, keeping the toolbar on its own case.
* The no-argument reload would re-select the server's `lastUsedCase`, and a
* tab's "+" launching through quick-start sets that to the tab's case while the
* toolbar stays where it was, so every later launch (a worktree's, a plain Run,
* a group's "New session") would otherwise move the toolbar there.
*/
_reloadCasesAfterRun() {
this.loadQuickStartCases(document.getElementById('quickStartCase').value || null);
},
async runClaude(options = {}) {
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
// that git worktree of the case and its tab is named after the worktree.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
const nameBase = wt ? wt.name : caseName;
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
@@ -2123,7 +2266,7 @@ Object.assign(CodemanApp.prototype, {
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
if (remoteIds[0]) {
await this.selectSession(remoteIds[0]);
this.loadQuickStartCases();
this._reloadCasesAfterRun();
}
this.terminal.focus();
return;
@@ -2202,7 +2345,7 @@ Object.assign(CodemanApp.prototype, {
// Auto-switch to the new session using selectSession (does proper refresh)
if (firstSessionId) {
await this.selectSession(firstSessionId);
this.loadQuickStartCases();
this._reloadCasesAfterRun();
}
this.terminal.focus();
@@ -2249,7 +2392,7 @@ Object.assign(CodemanApp.prototype, {
async runShell(options = {}) {
// `options.worktree`: see runClaude.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
const nameBase = wt ? wt.name : caseName;
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
@@ -2450,7 +2593,7 @@ Object.assign(CodemanApp.prototype, {
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
// session starts through the same path as any other, only in the worktree's folder.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
@@ -2685,7 +2828,13 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalKeepWarmEnabled').checked =
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
this.updateKeepWarmStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
// Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also
// what SSE calls when a scheduled run lands, so the rows stay current while the modal is open.
this.updateGitFetchStatus(sessionId);
const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled');
imageWatcherToggle.checked = session.imageWatcherEnabled ?? true;
// The server never watches a remote session's directory (it is on another host), so the row is hidden.
imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : '';
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
// Populate session name input with prefix/suffix split
@@ -2741,6 +2890,12 @@ Object.assign(CodemanApp.prototype, {
if (respawnTabBtn) respawnTabBtn.style.display = '';
}
// Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is
// never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes
// hidden the same way.
const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]');
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : '';
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
if (!isExternalCli) {
const ralphState = this.ralphStates.get(sessionId);
@@ -3022,11 +3177,114 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* The Git tab: the session's last scheduled run, and which values its rows show. A session with no choice of
* its own follows the app setting, so its switch and interval display the app's numbers and go inert (the
* same inert-row pattern as updateKeepWarmStatus above), because changing them here is what makes the session
* stop following. The app numbers come from the settings cache, so what this shows can be one settings load
* behind; the server decides from a fresh read, not from what the page displays.
*
* SSE calls this on every `session:updated` for the open session (app.js `_onSessionUpdated`), and a working
* session sends one on each working/idle change, so a control the user is in (`document.activeElement`) is
* left as it is: rewriting it put a half-typed interval back to the stored one before `onchange` could save it.
*/
updateGitFetchStatus(sessionId) {
const statusEl = document.getElementById('gitFetchStatus');
const followBox = document.getElementById('modalGitFetchFollow');
const box = document.getElementById('modalGitFetchEnabled');
const num = document.getElementById('modalGitFetchInterval');
if (!statusEl || !followBox || !box || !num || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
const app = this.loadAppSettingsFromStorage() || {};
// No choice of its own on either field means the session follows the app. A partial one (its own switch,
// the app's interval) means it does not, and the row that still follows shows the app's value, which is
// also the value the scheduler runs it with (a default here would show one number and fetch at another).
const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined;
const enabled = session.gitFetchEnabled ?? (app.gitFetchEnabled === true);
const interval = session.gitFetchIntervalSeconds ?? app.gitFetchIntervalSeconds ?? 300;
const focused = document.activeElement;
if (followBox !== focused) followBox.checked = follows;
if (box !== focused) box.checked = enabled;
if (num !== focused) num.value = interval;
for (const el of [box, num]) {
el.closest('.set-row')?.classList.toggle('set-row-disabled', follows);
el.disabled = follows;
}
const run = session.gitFetch;
const parts = run
? [
`Last run ${this._gitFetchAgo(run.ranAt)}${run.trigger === 'interval' ? ' on the interval' : ' by click'}`,
run.state === 'ok' ? `${run.repos} repositories` : run.state,
run.failed ? `${run.failed} failed` : '',
]
: [follows ? 'Nothing fetched on a timer yet, and this session follows the app setting.' : 'Nothing fetched on a timer yet for this session.'];
statusEl.textContent = parts.filter(Boolean).join(' · ');
statusEl.hidden = false;
},
/** 'just now', '4m', '2h': the short stamps the tab rows use, measured from a run's start. */
_gitFetchAgo(ts) {
const seconds = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (seconds < 60) return seconds < 10 ? 'just now' : `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
return `${Math.round(seconds / 3600)}h`;
},
async autoSaveGitFetch() {
if (!this.editingSessionId) return;
const follow = document.getElementById('modalGitFetchFollow').checked;
const num = document.getElementById('modalGitFetchInterval');
// `null` on the route means "no choice of your own", which is what following the app setting is.
const body = follow
? { enabled: null, intervalSeconds: null }
: {
enabled: document.getElementById('modalGitFetchEnabled').checked,
intervalSeconds: Math.min(3600, Math.max(30, parseInt(num.value, 10) || 300)),
};
// Enter fires `onchange` with the field still focused, and updateGitFetchStatus leaves a focused field
// alone, so the clamped number is written back here or the field would keep showing what was typed.
if (!follow) num.value = body.intervalSeconds;
try {
// `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a
// save. `_apiJson` is the one that says whether the call went through, and null here means it did not.
const data = await this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, {
method: 'POST',
body,
});
if (!data) {
this.showToast('Failed to save Git fetch settings', 'error');
return;
}
const session = this.sessions.get(this.editingSessionId);
const saved = data.gitFetchSettings;
if (session && saved) {
session.gitFetchEnabled = saved.enabled ?? undefined;
session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined;
}
this.updateGitFetchStatus(this.editingSessionId);
this.showToast('Git fetch settings saved', 'success');
} catch (err) {
this.showToast('Failed to save Git fetch settings: ' + err.message, 'error');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
const toggle = document.getElementById('modalImageWatcherEnabled');
const enabled = toggle.checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
const res = await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
// `_apiPost` hands back the Response without checking it, so a refused request (a 400) must not
// read as a save: put the switch back and say so.
if (!res?.ok) {
toggle.checked = !enabled;
this.showToast('Failed to toggle image watcher', 'error');
return;
}
// Update local session state
const session = this.sessions.get(this.editingSessionId);
if (session) {
@@ -3160,6 +3418,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
document.getElementById('git-tab').classList.toggle('hidden', tabName !== 'git');
// A switched-to section starts at its own top, not at the scroll offset the
// previous one was left at.
@@ -3960,6 +4219,15 @@ Object.assign(CodemanApp.prototype, {
set('cloneRepoUrl', '');
set('cloneCaseName', '');
set('cloneRepoRef', '');
set('cloneCasePath', '');
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
// server refuses it (it writes outside the cases directory and into the shared registry).
const customToggle = document.getElementById('cloneCustomPathToggle');
if (customToggle) customToggle.checked = false;
const customRow = document.getElementById('cloneCustomPathToggleRow');
const me = window.__codemanUser || {};
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
this.toggleCloneCustomPath();
const shallow = document.getElementById('cloneShallow');
if (shallow) shallow.checked = false;
const start = document.getElementById('cloneStartSession');
@@ -3987,6 +4255,50 @@ Object.assign(CodemanApp.prototype, {
}
},
/** Custom-folder row for Clone Repo: shows or hides the parent-folder field and rewords the name hint. */
toggleCloneCustomPath() {
const custom = document.getElementById('cloneCustomPathToggle');
const row = document.getElementById('cloneCustomPathRow');
if (!custom || !row) return;
row.style.display = custom.checked ? '' : 'none';
const hint = document.getElementById('cloneCaseNameHint');
if (hint) {
hint.textContent = custom.checked
? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.'
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
}
this.updateCloneCasePathPreview();
},
/** The folder the repository is cloned into: exactly what the field holds (no case name is added). */
_cloneTargetPath() {
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
},
updateCloneCasePathPreview() {
const hint = document.getElementById('cloneCasePathPreview');
if (!hint) return;
const target = this._cloneTargetPath();
hint.textContent = target ? `Will clone into: ${target}` : 'Pick an empty folder, or one that does not exist yet, to clone into.';
},
openCloneCasePathPicker() {
const input = document.getElementById('cloneCasePath');
PathPicker.open({
title: 'Choose the folder to clone into',
initialPath: input.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
input.value = path;
this.updateCloneCasePathPreview();
input.focus();
input.setSelectionRange(path.length, path.length);
},
});
},
_setCloneStatus(message, kind) {
const el = document.getElementById('cloneRepoStatus');
if (!el) return;
@@ -4135,6 +4447,12 @@ Object.assign(CodemanApp.prototype, {
return;
}
const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked;
if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) {
this.showToast('Choose the folder to clone into', 'error');
return;
}
this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, '');
try {
const res = await fetch('/api/cases/clone', {
@@ -4142,7 +4460,13 @@ Object.assign(CodemanApp.prototype, {
headers: { 'Content-Type': 'application/json' },
// Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one
// on the wire — omit the empty fields instead of sending null.
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
body: JSON.stringify({
name,
repository: url,
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
...(customFolder ? { path: this._cloneTargetPath() } : {}),
}),
});
const data = await res.json();
if (!data.success) {
@@ -4178,6 +4502,7 @@ Object.assign(CodemanApp.prototype, {
title: 'Select Existing Project Folder',
initialPath: pathInput.value.trim(),
directoriesOnly: true,
allowNewFolder: true,
onSelect: (path) => {
pathInput.value = path;
const nameInput = document.getElementById('linkCaseName');
@@ -5754,7 +6079,7 @@ Object.assign(CodemanApp.prototype, {
const created = r.data.worktree;
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
await this._launchOnce(mode, { worktree: { caseName, name: created.name, path: created.path } });
} finally {
if (btn) btn.disabled = false;
}
@@ -5766,26 +6091,22 @@ Object.assign(CodemanApp.prototype, {
if (!caseName || !wt) return;
const mode = this._selectedWorktreeMode();
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
await this._launchOnce(mode, { worktree: { caseName, name, path: wt.path } });
},
/**
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
* entries use, so the Run button's chosen mode is untouched afterwards.
* Start ONE session of `mode` through run(), the Run button's own entry (its launch lock, its
* per-mode paths), outside the toolbar's choices: `runOptions` names a worktree, or a case and
* tab group (a tab's "+"). The mode rides the call (`run({ mode })`) rather than a swap of the
* Run button's mode, so a mode the user picks while the launch is pending is never overwritten.
*/
async _launchInWorktree(worktree, mode) {
async _launchOnce(mode, runOptions) {
if (this._runInFlight) {
this.showToast?.('A launch is already in progress', 'info');
return;
}
const previous = this._runMode;
this._runMode = mode || previous;
try {
await this.run({ count: 1, worktree });
} finally {
this._runMode = previous;
}
if (runOptions.tabGroupId) this.toggleTabGroupCollapsed?.(runOptions.tabGroupId, false);
await this.run({ count: 1, ...runOptions, ...(mode ? { mode } : {}) });
},
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
+329 -35
View File
@@ -541,6 +541,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
// Agent inbox snapshot on disk: synced, default OFF.
document.getElementById('appSettingsAgentInboxPersist').checked = settings.agentInboxPersistEnabled === true;
// Experimental agent coordination (inspect/stage/submit routes): synced, default OFF.
document.getElementById('appSettingsAgentCoordination').checked = settings.agentCoordinationEnabled === true;
// Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both
// the Run-menu picker's generated entries and this settings panel's visibility;
// the endpoint list itself is server state, loaded on demand below.
@@ -568,6 +570,7 @@ Object.assign(CodemanApp.prototype, {
this.loadConfigBackups();
this._applyDoctorAdminGate();
this.loadWebhook();
this.loadDiscord();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -602,6 +605,12 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
// Git fetch on an interval: synced, default OFF, interval default 300 s (DEFAULT_GIT_FETCH_INTERVAL_SECONDS).
// Synced means the SERVER decides whether to fetch, so these keys stay out of the per-device display list
// below and are not stripped from a save: a stripped key would never reach the scheduler.
document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;
document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;
document.getElementById('appSettingsShowEditHistory').checked = settings.showEditHistory ?? defaults.showEditHistory ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -618,6 +627,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
// Default ON, so an absent key reads as enabled rather than as off.
document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
// overwrites the system clipboard on a gesture the user may have meant only as
// a way to read, so it is opt-in rather than a default anyone has to discover.
@@ -658,6 +669,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowTabDetachButton').checked =
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
// The tab's "+" (applyHeaderVisibilitySettings): per-device, OFF unless switched on.
document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true;
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
@@ -1722,6 +1735,115 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a
* settings-payload field: the bot token is a secret, so it never round-trips through settings.json
* or this page. The token box is write-only. Unlike the webhook group it has its own Save button
* only (the modal's main Save does not touch it), so a half-typed token is never sent by accident.
*/
_discordSay(text, bad = false) {
const out = document.getElementById('discordResult');
if (!out) return;
out.textContent = text;
out.style.display = text ? 'block' : 'none';
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
},
async loadDiscord() {
const group = document.getElementById('discordGroup');
if (!group) return;
const res = await this._api('/api/discord');
let body = null;
try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ }
if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server
const d = body.data;
group.style.display = '';
document.getElementById('discordEnabled').checked = d.enabled === true;
const token = document.getElementById('discordToken');
token.value = '';
token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal';
document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.';
document.getElementById('discordGuildId').value = d.guildId || '';
document.getElementById('discordParentChannelId').value = d.parentChannelId || '';
document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', ');
document.getElementById('discordThreadCreation').value = d.threadCreation;
document.getElementById('discordOnSessionDelete').value = d.onSessionDelete;
document.getElementById('discordStaleThreads').value = d.staleThreads || 'keep';
document.getElementById('discordPingMode').value = d.pingMode;
document.getElementById('discordScope').value = d.scope;
document.getElementById('discordStartupHint').checked = d.startupHint !== false;
const clearBtn = document.getElementById('discordClearBtn');
if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none';
const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` };
let line = gw[d.gateway] || '';
if (d.lastResult) {
const when = new Date(d.lastResult.at).toLocaleString();
line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`;
}
this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok));
},
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
async saveDiscord() {
const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean);
const payload = {
enabled: document.getElementById('discordEnabled').checked,
guildId: document.getElementById('discordGuildId').value.trim(),
parentChannelId: document.getElementById('discordParentChannelId').value.trim(),
allowedUserIds: ids,
threadCreation: document.getElementById('discordThreadCreation').value,
onSessionDelete: document.getElementById('discordOnSessionDelete').value,
staleThreads: document.getElementById('discordStaleThreads').value,
pingMode: document.getElementById('discordPingMode').value,
scope: document.getElementById('discordScope').value,
startupHint: document.getElementById('discordStartupHint').checked,
};
const token = document.getElementById('discordToken').value.trim();
if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it)
const res = await this._api('/api/discord', { method: 'PUT', body: payload });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
const error = body?.error || 'Could not save the Discord settings.';
this._discordSay(error, true);
return error;
}
await this.loadDiscord();
return '';
},
async clearDiscord() {
if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return;
const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._discordSay(body?.error || 'Could not remove the token.', true);
return;
}
await this.loadDiscord();
},
async testDiscord() {
const btn = document.getElementById('discordTestBtn');
if (btn) btn.disabled = true;
try {
if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown
this._discordSay('Sending…');
const res = await this._apiPost('/api/discord/test', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._discordSay(body?.error || 'Could not send the test.', true);
return;
}
const r = body.data;
this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok);
} finally {
if (btn) btn.disabled = false;
}
},
/**
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
@@ -2271,10 +2393,16 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true;
try {
const newEnabled = !isActive;
const gate = newEnabled ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false };
if (!gate.proceed) {
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
body: JSON.stringify({ tunnelEnabled: newEnabled, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
@@ -2283,6 +2411,14 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = false;
return;
}
// A stop the server did not store (a 409 for a settings.json that does not parse) left the
// tunnel running: say so, and leave the button showing it on.
const stopError = newEnabled ? '' : await this._settingsWriteError(res);
if (stopError) {
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
btn.disabled = false;
return;
}
if (newEnabled) {
this._showTunnelConnecting();
// Poll tunnel status as fallback in case SSE event is missed
@@ -2582,15 +2718,73 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* End an App Settings save whose tunnel enable did not go through (confirm cancelled, or
* refused by the server): the WHOLE settings PUT is off, so turn the tunnel toggle back
* off in the UI and localStorage. Other settings persisted to localStorage still apply locally.
*/
_abandonTunnelEnable(settings, keepOpen) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
if (!keepOpen) this.closeAppSettings();
},
/** The one SECURITY WARNING prompt for an unauthenticated public tunnel. */
_askUnauthTunnel() {
return confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
},
/**
* #706: ask BEFORE a tunnel enable whenever the server has no login (no CODEMAN_PASSWORD,
* not multi-user), every time, whatever the server would accept. With
* CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL set the PUT is never refused, so the refusal
* handler below would never ask. The ONE gate for the welcome link, the tunnel panel and
* the App Settings save. A tunnel that is already running is not being turned on, and
* the server does not guard it either. When the status cannot be read, the PUT goes out
* without the acknowledgement and `_handleTunnelEnableRefusal` stays the fallback.
* @returns {Promise<{ proceed: boolean, acknowledge: boolean }>} proceed=false: cancelled,
* send nothing; acknowledge: send `acknowledgeUnauthTunnel: true` with the enable.
*/
async _confirmTunnelEnable() {
let status = null;
try {
const res = await fetch('/api/tunnel/status');
if (res.ok) {
const env = await res.json();
status = env?.success === true ? env.data : env;
}
} catch {
/* unknown: the 403 handler asks instead */
}
if (!status || status.running || status.loginConfigured !== false) return { proceed: true, acknowledge: false };
if (!this._askUnauthTunnel()) {
this.showToast('Tunnel not enabled', 'info');
return { proceed: false, acknowledge: false };
}
return { proceed: true, acknowledge: true };
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* CODEMAN_PASSWORD is set and the unauthenticated-tunnel opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast. Since #706 this is the
* fallback for when `_confirmTunnelEnable` could not read the tunnel status.
* @param {Response|null} res - the fetch Response from the settings PUT
* @param {object} [sent] - what that PUT sent, resent with the acknowledgement: the refusal
* rejected all of it, so an App Settings save retried with the tunnel keys alone lost the rest
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
async _handleTunnelEnableRefusal(res) {
async _handleTunnelEnableRefusal(res, sent = { tunnelEnabled: true }) {
if (!res || res.ok) return false;
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
let errorCode = '';
@@ -2612,14 +2806,7 @@ Object.assign(CodemanApp.prototype, {
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
const confirmed = confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
const confirmed = this._askUnauthTunnel();
if (!confirmed) {
this._dismissTunnelConnecting?.();
this.showToast('Tunnel not enabled', 'info');
@@ -2629,7 +2816,7 @@ Object.assign(CodemanApp.prototype, {
const retry = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }),
});
if (retry.ok) {
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
@@ -2664,16 +2851,28 @@ Object.assign(CodemanApp.prototype, {
return;
}
try {
const gate = enable ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false };
if (!gate.proceed) {
this.closeTunnelPanel();
return;
}
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
body: JSON.stringify({ tunnelEnabled: enable, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
this.closeTunnelPanel();
return;
}
// A stop the server did not store left the tunnel running (see toggleTunnelFromWelcome).
const stopError = enable ? '' : await this._settingsWriteError(res);
if (stopError) {
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
this.closeTunnelPanel();
return;
}
if (enable) {
this._updateTunnelIndicator(false);
const indicator = document.getElementById('tunnelIndicator');
@@ -2892,7 +3091,11 @@ Object.assign(CodemanApp.prototype, {
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
// After a save the server did not store, this device's blob is ahead of the server, so the
// two server-side changes judged against it (the gesture reload, the plan-usage collection
// flip) are judged against the blob that save started from until a save lands.
const _prevServer = this._unsavedSettingsBaseline ?? _prev;
const _prevGestureEnabled = (_prevServer.gestureControlEnabled ?? false) === true;
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
// as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
@@ -2929,6 +3132,7 @@ Object.assign(CodemanApp.prototype, {
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
...this._configBackupRetentionPayload(_prev),
agentInboxPersistEnabled: document.getElementById('appSettingsAgentInboxPersist').checked,
agentCoordinationEnabled: document.getElementById('appSettingsAgentCoordination').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2948,6 +3152,14 @@ Object.assign(CodemanApp.prototype, {
// Clamped here and again on the server; an empty or odd value falls back to the default.
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
// Clamped to the schema's bounds here too: a value outside them fails the whole save, and these keys
// must survive the strip below because the server's scheduler is what reads them.
gitFetchEnabled: document.getElementById('appSettingsGitFetchEnabled').checked,
gitFetchIntervalSeconds: Math.min(
3600,
Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300)
),
showEditHistory: document.getElementById('appSettingsShowEditHistory').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -2958,6 +3170,7 @@ Object.assign(CodemanApp.prototype, {
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
@@ -2978,6 +3191,7 @@ Object.assign(CodemanApp.prototype, {
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked,
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -3050,6 +3264,8 @@ Object.assign(CodemanApp.prototype, {
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
// Turning Keep Size Under Keyboard off while it holds releases it now.
this._reconcileKeyboardHold?.();
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
this.applyTerminalFontWeights?.(settings);
@@ -3199,9 +3415,12 @@ Object.assign(CodemanApp.prototype, {
// switch collection off for every desktop, whose chip then went stale with
// no error anywhere. An explicit toggle on any device still writes it, in
// either direction.
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
const _chipFlip = this.planUsageCollectionFlip(_prevServer, settings.showPlanUsageLimits);
const {
localEchoEnabled: _leo,
// Per-device (it is about this screen's keyboard) and absent from
// SettingsUpdateSchema (.strict()), so sending it would 400 the whole PUT.
keyboardHoldsRows: _khr,
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
@@ -3248,7 +3467,10 @@ Object.assign(CodemanApp.prototype, {
gitStatusTree: _gst,
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
// Per-device header button (edit-history-ui.js), likewise absent from the .strict() schema.
showEditHistory: _seh,
showTabDetachButton: _tdb,
showTabNewButton: _tnb,
// Detach Tiles: what a tile drag does is a property of this device's
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
tileDetachEnabled: _tde,
@@ -3268,16 +3490,29 @@ Object.assign(CodemanApp.prototype, {
...serverSettings
} = settings;
let webhookError = '';
let saveError = '';
let serverSaved = false;
try {
// #706: a save that turns the tunnel on asks first when the server has no login
// (one already running is not asked about, see _confirmTunnelEnable).
const tunnelGate = settings.tunnelEnabled && !settingsReadOnly
? await this._confirmTunnelEnable()
: { proceed: true, acknowledge: false };
if (!tunnelGate.proceed) {
this._unsavedSettingsBaseline = _prevServer;
this._abandonTunnelEnable(settings, keepOpen);
return;
}
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
// would refuse it with a 403, the plan-usage collection flip included.
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
const body = {
...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
};
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', body);
// Multi-user mode, a non-admin whose role was not known when this save started
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
@@ -3292,20 +3527,25 @@ Object.assign(CodemanApp.prototype, {
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
if (!keepOpen) this.closeAppSettings();
// so it doesn't look enabled. Only a 403: any other failure with the switch on is
// not about the tunnel, and turning the switch off for it would be wrong too.
const tunnelRefused = !refusedAsNonAdmin && settings.tunnelEnabled && res?.status === 403;
if (tunnelRefused && (await this._handleTunnelEnableRefusal(res, body))) {
this._unsavedSettingsBaseline = _prevServer;
this._abandonTunnelEnable(settings, keepOpen);
return;
}
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
// evidence the server kept the flags the Apply refresh below reads.
serverSaved = !!res?.ok;
// evidence the server kept the flags the Apply refresh below reads (a tunnel 403 still
// here was retried with the whole save and accepted).
serverSaved = !!res?.ok || tunnelRefused;
// Any other failure (a 409 for a settings.json that does not parse, a 400, a 500, a dropped
// connection) stored nothing on the server, so the save must not say it did. A known
// non-admin sent nothing (res is null).
saveError =
settingsReadOnly || refusedAsNonAdmin || tunnelRefused ? '' : await this._settingsWriteError(res);
this._unsavedSettingsBaseline = saveError ? _prevServer : null;
// Save model configuration separately. For a non-admin it is not sent either, and the
// answer says whether this save would have changed it.
@@ -3316,7 +3556,9 @@ Object.assign(CodemanApp.prototype, {
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
if (saveError) {
this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 });
} else if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
// ONE toast for the whole save, and only when the user really changed a server-wide
@@ -3329,7 +3571,7 @@ Object.assign(CodemanApp.prototype, {
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
// starts nothing: the box only shows what the server already has)
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin && !saveError) {
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
}
} catch (err) {
@@ -3341,9 +3583,10 @@ Object.assign(CodemanApp.prototype, {
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
// A failed settings PUT keeps the modal open too, with the form as the user left it.
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else if (!keepOpen) {
} else if (!keepOpen && !saveError) {
this.closeAppSettings();
}
@@ -3354,10 +3597,10 @@ Object.assign(CodemanApp.prototype, {
// The gesture overlay is injected at page render (server reads
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
// persisted the new value (not after a failed PUT: the reload would render the old one).
// `?? false`: a non-admin's save puts a synced key the server never stored back to
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
if (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
@@ -3406,6 +3649,35 @@ Object.assign(CodemanApp.prototype, {
return true;
},
/**
* Why a settings write did not land, or '' when it did: the server's `error` (a 409 when
* settings.json does not parse, a 400, ...), else the HTTP status, else, for the null that
* `_apiPut` answers a dropped connection with, that the server could not be reached. Reads a
* clone, like _noteSettingsWriteRefusal.
* @returns {Promise<string>}
*/
async _settingsWriteError(res) {
if (!res) return 'the server could not be reached';
if (res.ok) return '';
let body = null;
try {
body = await res.clone().json();
} catch {
/* not JSON: the status is all there is */
}
return body?.error || `HTTP ${res.status}`;
},
/**
* The end of a background settings write (run mode, respawn presets): the admin-only refusal is
* _noteSettingsWriteRefusal's, and any other failure says that `what` stayed on this device only.
*/
async _noteSettingsWriteResult(res, what) {
if (await this._noteSettingsWriteRefusal(res)) return;
const error = await this._settingsWriteError(res);
if (error) this.showToast(`${what} saved on this device, not on the server: ${error}`, 'warning', { duration: 0 });
},
/**
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
@@ -3717,6 +3989,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('customModelHostApiKey').value = ''; // the server never returns the real value (apiKeySet is a bool)
document.getElementById('customModelHostApiKey').placeholder = host?.apiKeySet ? '•••••••• (unchanged if left blank)' : '';
document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer';
// User-set fallback, round-tripped verbatim (discovery never touches it, so the
// cached copy is authoritative); blank means "no default", cleared on save.
document.getElementById('customModelHostDefaultContextLength').value = host?.defaultContextLength ?? '';
this._populateCustomModelDefaultSelect(host);
document.getElementById('customModelHostEditor').style.display = '';
},
@@ -3742,6 +4017,18 @@ Object.assign(CodemanApp.prototype, {
const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim();
const apiKeyInput = document.getElementById('customModelHostApiKey').value;
const authStyle = document.getElementById('customModelHostAuthStyle').value;
// Blank means "no default" (key omitted, so PUT clears it); a value must satisfy
// the same bound the server schema enforces, caught here for a friendlier message.
const defaultContextLengthInput = document.getElementById('customModelHostDefaultContextLength').value.trim();
let defaultContextLength;
if (defaultContextLengthInput !== '') {
const parsed = Number(defaultContextLengthInput);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100000000) {
this.showToast('Default context length must be a whole number of tokens between 1 and 100,000,000', 'warning');
return;
}
defaultContextLength = parsed;
}
const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined;
if (!id || !label || !baseUrl) {
this.showToast('Id, label and base URL are all required', 'warning');
@@ -3762,6 +4049,7 @@ Object.assign(CodemanApp.prototype, {
baseUrl,
authStyle,
defaultModelId,
defaultContextLength,
apiKey: apiKeyInput || undefined,
models: existing?.models,
lastDiscoveredAt: existing?.lastDiscoveredAt,
@@ -4387,6 +4675,9 @@ Object.assign(CodemanApp.prototype, {
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
// The tab's "+" (new session beside it): per-device and opt-in everywhere,
// like the pop-out button. On a phone it also widens the active tab (mobile.css).
document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true);
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
@@ -4539,6 +4830,8 @@ Object.assign(CodemanApp.prototype, {
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
// match the setting, so a live toggle needs no reload.
this.applyGitStatusVisibility?.();
// Header Edit history button (edit-history-ui.js): opt-in, per-device, marker-class hidden.
this.applyEditHistoryVisibility?.();
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
@@ -4896,14 +5189,15 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory',
'showTabDetachButton',
'showTabNewButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
+528 -4
View File
@@ -616,6 +616,52 @@ body {
padding-left: var(--lineage-spine-channel);
}
/* L-shaped strip (`_syncTabStripCorner()`, app.js): a wrapped classic strip
runs the header's full width, the toolbar sits over its top-right corner,
and only the rows beside the toolbar stop short of it. A flex line cannot
be shortened on its own, so the strip lays its tabs out inline, around a
float the size of the toolbar's box (measured into --tab-corner-width and
--tab-corner-height). The strip is a scroll container, so it contains the
float and is never shorter than the toolbar it now holds up; a strip that
would actually scroll keeps the plain wrap instead. Inline tabs space their
rows with a bottom margin (the old row-gap, lineage's included); the strip
carries no font size of its own, so the whitespace between tabs is 0 wide. */
.header:has(> .session-tabs-host > .session-tabs.tabs-corner) > .header-right {
position: absolute;
top: 0.35rem;
right: 0.75rem;
}
.session-tabs-host > .session-tabs.tabs-corner {
display: block;
font-size: 0;
padding-bottom: 0;
}
.session-tabs-host > .session-tabs.tabs-corner::before {
content: '';
float: right;
width: var(--tab-corner-width, 0px);
height: var(--tab-corner-height, 0px);
}
.session-tabs-host > .session-tabs.tabs-corner > .session-tab {
display: inline-flex;
vertical-align: top;
margin: 0 2px var(--lineage-row-gap, 4px) 0;
}
/* The first tab is capped to the room beside the toolbar: one wider than that (a
selected one shows its gear and close) has its name trimmed to fit, so the
layout never depends on which tab is selected. */
.session-tabs-host > .session-tabs.tabs-corner > .session-tab.tab-corner-fit {
max-width: var(--tab-corner-first-max, none);
}
.session-tabs-host > .session-tabs.lineage-tree.tabs-corner {
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
}
.session-tabs::-webkit-scrollbar {
width: 4px;
height: 0;
@@ -2992,6 +3038,33 @@ html[data-header-stats] .header-system-stats .stat-item[hidden] {
transform: rotate(45deg);
}
/* "+" (new session beside this tab, app.openTabNewSessionMenu): same reveal as the gear. */
.session-tab .tab-new {
opacity: 0;
width: 0;
padding: 0;
font-size: 1rem;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
overflow: hidden;
transition: opacity 0.15s, width 0.15s, padding 0.15s;
}
.session-tab.active .tab-new {
opacity: 1;
width: auto;
padding: 0 0.3rem;
}
.session-tab .tab-new:hover {
color: var(--accent-hover);
}
/* Per-device toggle (App Settings → Tab Bar), mirrored as tabs-show-new on <html>
by settings-ui.js. !important so no layout's reveal rule (sidebar, touch) wins. */
html:not(.tabs-show-new) .session-tab .tab-new {
display: none !important;
}
/* ===== Session detach / undock (beta/session-detach) ===================== */
/* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */
@@ -3145,7 +3218,7 @@ html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail
pointer-events: auto;
}
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) {
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-new, .tab-detach, .tab-close) {
display: none;
}
@@ -7741,6 +7814,52 @@ html[data-skin="og"] .welcome-primary:hover {
opacity: 0.8;
}
/* The picker's search box reuses .session-sidebar-filter-input's theme-token
styling (same reasoning as the .set-scope pill above): only its own spacing
and the no-matches line are added here. 0.35rem is the standard gap the
modal's neighbouring form fields already use (`.form-hint`'s own margin-top):
the global `* { margin: 0 }` reset left the box sitting flush under the CLI
hint line above it, and the old 0.5rem read as too big a gap above the list
(compounded by the always-visible no-matches line the rule below fixes). */
#customModelPickModal .custom-model-pick-search {
margin-top: 0.35rem;
margin-bottom: 0.35rem;
}
#customModelPickModal .custom-model-pick-no-matches {
text-align: center;
padding: 1rem 0;
}
/* "Nothing is marked: this endpoint did not report which model it has loaded": the same
`.form-hint` `display: block` beats `[hidden]` trap the two rules below fix for the
no-matches line and the filtered rows, so the attribute needs the same re-assertion. */
#customModelPickModal .custom-model-pick-unknown {
padding-bottom: 0.15rem;
}
#customModelPickModal .custom-model-pick-unknown[hidden] {
display: none !important;
}
/* Same class of bug as the row rule below: the no-matches line carries
`.form-hint`, whose `display: block` outranks the UA `[hidden]` rule, so
`filterCustomModelPick()` toggling `hidden` changed nothing on screen and
"No models match" showed while every model was listed with an empty search
box. The `[hidden]` re-assertion makes the attribute hide the line again. */
#customModelPickModal .custom-model-pick-no-matches[hidden] {
display: none !important;
}
/* A filtered-out row's `hidden` must actually hide it: `.run-mode-option` sets
`display: flex` (and mobile.css restates the class in a media query), which
outranks the UA `[hidden] { display: none }` rule, so the filter would
silently do nothing without this re-assertion. Scoped to this modal's rows;
the Run menu's own .run-mode-option entries never take `hidden`. */
#customModelPickModal .run-mode-option[hidden] {
display: none !important;
}
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
@@ -12544,13 +12663,21 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
/* ---- File Viewer rendered markdown ----
Styling comes from the Response Viewer's .rv-text rules; .rv-text itself
carries no padding or base font (the chat card supplies those). */
carries no padding or base font (the chat card supplies those).
No width cap. The window is whatever the user drags or expands it to, so the
document must reflow to fill it; the fixed 960px reading column this rule
shipped before the resize grip existed stops reflow once the window is wider
than the cap. --rv-content-max: none lets ASCII-diagram blocks
(.rv-text .rv-diagram-wrap, capped by that variable with a 720px fallback
meant for the Response Viewer) follow the rest of the document too. Pinned by
test/file-preview-window.test.ts. */
.file-preview-body > .file-preview-md {
padding: 1rem 1.25rem 2rem;
font-size: 15px;
line-height: 1.55;
max-width: 960px;
--rv-content-max: none;
}
/* Relative links in the document are rebased onto a.rv-path so the Response
@@ -15694,6 +15821,7 @@ body.touch-device.cjk-input-visible .main {
.path-picker-up,
.path-picker-refresh,
.path-picker-newfolder,
.path-picker-hidden {
flex: 0 0 38px;
height: 38px;
@@ -15718,6 +15846,45 @@ body.touch-device.cjk-input-visible .main {
border-color: var(--accent);
}
.path-picker-newfolder {
flex: 0 0 auto;
padding: 0 10px;
white-space: nowrap;
}
.path-picker-newfolder[hidden],
.path-picker-newfolder-form[hidden] {
display: none;
}
.path-picker-newfolder-form {
display: flex;
gap: 8px;
padding: 0 12px 10px;
}
.path-picker-newfolder-name {
flex: 1 1 auto;
min-width: 0;
height: 38px;
padding: 0 10px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-newfolder-create,
.path-picker-newfolder-cancel {
height: 38px;
padding: 0 12px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.path-picker-up:disabled {
opacity: 0.35;
cursor: default;
@@ -19869,6 +20036,7 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .ta
actions permanently visible on the active session — no layout jitter when
the pointer crosses the list. */
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-gear,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-new,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-detach,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-close {
opacity: 1;
@@ -20383,6 +20551,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .se
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-gear,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-new,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-detach,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-close {
display: none;
@@ -22152,6 +22321,42 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
gap: 0.25rem;
}
/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session
there is nothing to fetch for (no repository, remote or Docker). Scoped to the Git panel's header: the
File Viewer's Back/Forward and the folder grid's Up button are .btn-icon-sm too, and keep their own look. */
.git-status-actions .btn-icon-sm[disabled] {
color: var(--text-dim);
opacity: 0.45;
cursor: default;
}
.git-status-actions .btn-icon-sm[disabled]:hover {
background: none;
}
.git-status-actions .btn-icon-sm[aria-busy='true'] {
color: var(--text);
animation: btn-icon-busy 1.1s ease-in-out infinite;
}
@keyframes btn-icon-busy {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 0.9;
}
}
/* No pulse under reduced motion: the button holds the pulse's brighter step, still dimmer than at rest. */
@media (prefers-reduced-motion: reduce) {
.git-status-actions .btn-icon-sm[aria-busy='true'] {
animation: none;
opacity: 0.7;
}
}
.git-status-body {
flex: 1;
overflow-y: auto;
@@ -22171,7 +22376,8 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
.git-status-empty,
.git-status-ok,
.git-status-note,
.git-status-more {
.git-status-more,
.git-status-fetch-note {
color: var(--text-muted);
padding: 0.25rem 0;
}
@@ -22180,6 +22386,24 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
color: rgb(34, 197, 94);
}
/* The last Fetch's result: a summary line, then a row per repository it could not fetch. */
.git-status-fetch-note {
margin-top: 0.6rem;
padding-top: 0.4rem;
border-top: 1px solid var(--border);
}
.git-status-fetch-summary {
margin-bottom: 0.2rem;
}
.git-status-fetch-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.1rem 0;
}
.git-status-branchline {
display: flex;
flex-wrap: wrap;
@@ -22459,6 +22683,297 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
font-size: 0.68rem;
}
/* ── Edit history: header button + panel (edit-history-ui.js) ───────────────── */
/* "Edit history" header button: opt-in (App Settings → Header & Panels), hidden by default.
Same marker pattern as the Attachments button: a base inline-flex !important so an inline
style can't override it, and a more-specific marker rule to hide. Phones hide it regardless
(mobile.css). */
.btn-edit-history {
display: inline-flex !important;
}
.btn-edit-history.btn-edit-history--hidden {
display: none !important;
}
.btn-edit-history[aria-expanded='true'] {
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Same frame as the Git and Files windows: draggable by the header, resizable at the corner. */
.edit-history-panel {
position: fixed;
top: calc(var(--header-height) + 10px);
/* Left of the Git window's default spot; on a narrow viewport slide right so the left edge stays on screen. */
right: clamp(8px, calc(100vw - 428px), 410px);
width: 420px;
max-width: calc(100vw - 16px);
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
max-height: 640px;
min-width: 260px;
min-height: 220px;
background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 100;
display: none;
flex-direction: column;
overflow: hidden;
resize: both;
}
.edit-history-panel.visible {
display: flex;
}
.edit-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--bg-input);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
cursor: move;
user-select: none;
touch-action: none;
}
.edit-history-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
}
.edit-history-count {
margin-left: 0.4rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
font-weight: 400;
color: var(--text-muted);
}
.edit-history-actions {
display: flex;
gap: 0.25rem;
}
.edit-history-controls {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.9rem;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--border);
font-size: 0.68rem;
color: var(--text-muted);
flex-shrink: 0;
}
.edit-history-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.edit-history-body {
flex: 1;
overflow-y: auto;
padding: 0.4rem 0.5rem;
font-size: 0.74rem;
color: var(--text);
}
.edit-history-empty,
.edit-history-note,
.edit-history-more {
color: var(--text-muted);
padding: 0.25rem 0;
}
.edit-history-note--warn {
color: #e0a030;
}
.edit-history-entry {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.15rem 0.25rem;
border-radius: 4px;
min-width: 0;
cursor: pointer;
}
.edit-history-entry:hover,
.edit-history-entry:focus-visible,
.edit-history-entry--open {
background: var(--bg-hover);
outline: none;
}
.edit-history-time {
flex: 0 0 auto;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.64rem;
color: var(--text-muted);
}
.edit-history-badge {
flex: 0 0 auto;
min-width: 2rem;
text-align: center;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #e0a030;
}
.edit-history-badge--new {
color: rgb(34, 197, 94);
}
.edit-history-badge--image {
color: var(--text-muted);
align-self: center;
}
.edit-history-badge--image svg {
display: block;
margin: 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Only the name itself opens the preview: the rest of the row (the spacer) toggles the diff. */
/* The folder part gives way first, so the file name stays readable (…/icons/bremse_alt1.png). */
.edit-history-path {
display: inline-flex;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
padding: 0;
border: 0;
background: none;
color: var(--text);
text-align: left;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
cursor: pointer;
}
.edit-history-path-dir {
/* Shrinks long before the name does; the name only gives way once the folder is at min-width. */
flex: 0 10000 auto;
min-width: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
/* Ellipsis at the START of the folder (rtl); the inner <bdi dir="ltr"> keeps its text in order. */
direction: rtl;
text-align: left;
opacity: 0.7;
}
.edit-history-path-name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-history-path:hover,
.edit-history-path:focus-visible {
text-decoration: underline;
}
.edit-history-spacer {
flex: 1;
align-self: stretch;
min-width: 0.5rem;
min-height: 1em;
}
.edit-history-stats {
flex: 0 0 auto;
display: inline-flex;
gap: 0.3rem;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.64rem;
}
.edit-history-add {
color: rgb(34, 197, 94);
}
.edit-history-del {
color: #e5534b;
}
.edit-history-agent {
flex: 0 1 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 0.4rem;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.62rem;
color: var(--text-muted);
}
.edit-history-thumb {
display: block;
max-width: 160px;
max-height: 90px;
margin: 0.15rem 0 0.35rem 3.2rem;
border: 1px solid var(--border);
border-radius: 4px;
object-fit: contain;
cursor: zoom-in;
}
.edit-history-diff {
margin: 0.2rem 0 0.5rem 0.25rem;
}
.edit-history-group {
margin-bottom: 0.45rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--border);
}
.edit-history-group-head {
display: flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.2rem 0.25rem;
min-width: 0;
}
.edit-history-group-head .edit-history-path {
font-weight: 600;
}
.edit-history-group-count {
flex: 0 0 auto;
font-size: 0.64rem;
color: var(--text-muted);
}
.edit-history-older {
margin: 0.5rem auto 0.25rem;
}
/* ============================================================
Tab grouping by state (tabArrangement: 'state', opt-in;
Discussion #426 option C)
@@ -23199,3 +23714,12 @@ html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
border: 1px solid var(--control-border);
color: var(--text-muted);
}
/* Rows held under the on-screen keyboard (keyboardHoldsRows; keyboardClipPx in
constants.js): the screen stays taller than its box and is scrolled up by
--kb-clip, set on #terminalContainer by _applyKeyboardClip (terminal-ui.js).
The helper textarea, IME preview, link layer and both echo overlays live
inside .xterm-screen, so they move with it, and every hit test reads rects. */
body.keyboard-visible .terminal-container .xterm-screen {
transform: translateY(calc(-1 * var(--kb-clip, 0px)));
}
+7
View File
@@ -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,6 +328,12 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(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 }) || []),
@@ -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') {
+10 -6
View File
@@ -244,6 +244,8 @@
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();
@@ -342,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;
@@ -644,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;
+310 -84
View File
@@ -767,6 +767,7 @@ Object.assign(CodemanApp.prototype, {
scrollback: scrollback,
allowTransparency: true,
allowProposedApi: true,
linkHandler: this._terminalOsc8LinkHandler(),
});
this.fitAddon = new FitAddon.FitAddon();
@@ -1060,6 +1061,9 @@ Object.assign(CodemanApp.prototype, {
if (MobileDetection.isTouchDevice()) {
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
// Keep the live content above the keyboard while the rows are held.
this.terminal.onCursorMove(() => this._applyKeyboardClip());
this.terminal.onRender(() => this._applyKeyboardClip());
}
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
@@ -2104,6 +2108,7 @@ Object.assign(CodemanApp.prototype, {
// Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its
// connect() key handler and onData) wires its own controller the same way
// for every grid tile and the split's Pane B; keep the two in step.
// create() returns null unless the primary pointer is coarse (#716).
try {
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
textarea: this.terminal.textarea,
@@ -2141,13 +2146,16 @@ Object.assign(CodemanApp.prototype, {
/**
* A clicked path as the preview needs it: relative paths resolved against the
* session's working directory, absolute and `~/` paths unchanged
* directory the session's pane is in now, else its working directory
* (`relativeResolutionBase`), absolute and `~/` paths unchanged
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
* provider and the response viewer, so one path opens the same file in both.
*/
_resolveLinkedFilePath(filePath, sessionId) {
if (typeof resolveLinkedFilePath !== 'function') return filePath;
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
const session = this.sessions?.get?.(sessionId);
const base = typeof relativeResolutionBase === 'function' ? relativeResolutionBase(session) : session?.workingDir;
return resolveLinkedFilePath(filePath, base);
},
/**
@@ -2208,8 +2216,11 @@ Object.assign(CodemanApp.prototype, {
// links as the one file it is.
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
// `terminalLinkLine` widens that once more to the rows of a URL the CLI
// wrapped short of the edge (a table cell or an indented paragraph),
// where nothing marks the break and no row is full width.
const MAX_STITCHED_ROWS = 12;
const logical = window.CodemanTerminalLines?.terminalPathLine(
const logical = window.CodemanTerminalLines?.terminalLinkLine(
buffer,
bufferLineNumber - 1,
terminal.cols,
@@ -2227,8 +2238,16 @@ Object.assign(CodemanApp.prototype, {
return { x: cell.col + 1, y: cell.row + 1 };
};
// Paths a CLI word-wrapped inside a table cell (terminalCellWrappedPaths,
// constants.js). They follow their own cell down the rows, which the
// linear stitch above cannot do for a multi-column table, so they are
// found from the buffer directly and claim their cells over the rest.
const cellWrappedPaths = (
window.CodemanTerminalLines?.terminalCellWrappedPaths?.(buffer, bufferLineNumber - 1, 4) ?? []
).filter((hit) => !/(^|\/)\.\.(\/|$)/.test(hit.path));
// A path needs a `/` or an extension's `.`; a line with neither has no link.
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
if (!lineText || (!lineText.includes('/') && !lineText.includes('.') && cellWrappedPaths.length === 0)) {
callback(undefined);
return;
}
@@ -2240,44 +2259,46 @@ Object.assign(CodemanApp.prototype, {
const taken = [];
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority
//
// A single `&` is PART of the URL: it separates query parameters, so excluding
// it truncated every real query string (`?post=1479&action=edit` linked only
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
// that is the shell operator and never appears inside a URL. A lone trailing
// `&` is trimmed below with the other trailing punctuation.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
const addUrlLink = (url, matchIndex) => {
// Strip trailing punctuation that's likely not part of the URL
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
const startCol = lineText.indexOf(cleaned, matchIndex);
if (startCol === -1) return;
const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority.
// The grammar (balanced parens, `&` vs `&&`, trailing punctuation) is
// findTerminalUrls() in constants.js, which the wrap-continuation extent
// above reads URLs with too, so the two cannot disagree.
const addUrlLink = ({ url, startIndex }) => {
const start = coordAt(startIndex);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
taken.push({ start: startCol, end: startCol + cleaned.length });
taken.push({ start: startIndex, end: startIndex + url.length });
links.push({
text: cleaned,
range: { start, end },
decorations: { pointerCursor: true, underline: true },
activate(_event, text) {
// A `localhost` link tapped from another device can only work
// through the server: route it into a proxied web tab
// (webview-tabs.js). Anything else opens as before.
if (self.openLinkThroughWebTabIfLoopback?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
// One link per terminal row the URL occupies, each opening the WHOLE
// URL. A single range spanning rows would also underline (and make
// clickable) the indent and table columns between the pieces of a
// URL the CLI wrapped. A single-row URL keeps its original range.
const endIndex = startIndex + url.length;
const pieces = [];
let pieceStart = startIndex;
for (let i = startIndex + 1; i < endIndex; i++) {
if (coordAt(i).y !== coordAt(i - 1).y) {
pieces.push([pieceStart, i]);
pieceStart = i;
}
}
pieces.push([pieceStart, endIndex]);
for (let n = 0; n < pieces.length; n++) {
const [from, to] = pieces[n];
links.push({
text: url,
range: { start: coordAt(from), end: pieces.length === 1 ? coordAt(to) : coordAt(to - 1) },
decorations: { pointerCursor: true, underline: true },
activate() {
self._openTerminalUrl(url);
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
}
};
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
@@ -2300,6 +2321,59 @@ Object.assign(CodemanApp.prototype, {
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
const activateFilePath = (text, folder = false, verifiedSessionId = null) => {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
//
// Text goes to the log viewer, which follows a file that is still
// being written — but ONLY where it can actually read: it spawns
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
// out-of-workspace path there answered "Path must be within
// working directory or allowed log directories" while the SAME
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = verifiedSessionId || getSessionId();
if (!sessionId) return;
const target = self._resolveLinkedFilePath(text, sessionId);
const alternative = !folder && logical.pathAlternatives?.find((candidate) => candidate.path === text);
if (alternative && !verifiedSessionId) {
return (async () => {
const original = await self._registerExternalPreview(target, sessionId);
// Attachment policy must not gate the workspace's normal opener.
if (original.status !== 404) return activateFilePath(target, false, sessionId);
const path = self._resolveLinkedFilePath(alternative.spacedPath, sessionId);
const registered = await self._registerExternalPreview(path, sessionId);
if (registered.status === 404) return activateFilePath(target, false, sessionId);
if (!registered.attachmentId) {
self.showToast(registered.error || 'Cannot open this file', 'error');
return;
}
// The normal opener rechecks its guards; attachment registration deduplicates the path.
return activateFilePath(path, false, sessionId);
})();
}
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
};
const addLink = (filePath, matchIndex, folder = false) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
@@ -2318,43 +2392,8 @@ Object.assign(CodemanApp.prototype, {
pointerCursor: true,
underline: true,
},
activate(event, text) {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
//
// Text goes to the log viewer, which follows a file that is still
// being written — but ONLY where it can actually read: it spawns
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
// out-of-workspace path there answered "Path must be within
// working directory or allowed log directories" while the SAME
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
const target = self._resolveLinkedFilePath(text, sessionId);
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
activate(_event, text) {
return activateFilePath(text, folder);
},
hover() {
setHovered(true);
@@ -2368,9 +2407,8 @@ Object.assign(CodemanApp.prototype, {
// Match all patterns — URLs first so they take priority
let match;
urlPattern.lastIndex = 0;
while ((match = urlPattern.exec(lineText)) !== null) {
addUrlLink(match[0], match.index);
for (const reference of window.CodemanTerminalLines?.findTerminalUrls?.(lineText) ?? []) {
addUrlLink(reference);
}
cmdPattern.lastIndex = 0;
@@ -2387,6 +2425,54 @@ Object.assign(CodemanApp.prototype, {
addLink(match[1], match.index);
}
// One link per row segment, each opening the WHOLE path (as for a wrapped
// URL). A fragment of the same cells linked by another detector (a
// relative `243/after-overview.png`, say) would open the wrong file, so
// the cell-wrapped path displaces it.
// A path the line stitch already joined whole (a single-column wrap, which
// terminalPathLine handles) keeps its one link.
const cellOnlyPaths = cellWrappedPaths.filter(
(hit) =>
!links.some(
(l) =>
l.text === hit.path &&
l.range.start.y === hit.segments[0].row + 1 &&
l.range.start.x === hit.segments[0].startCol + 1
)
);
if (cellOnlyPaths.length > 0) {
const claimed = cellOnlyPaths.flatMap((hit) => hit.segments);
const overlapsClaim = (link) =>
claimed.some((seg) => {
const y = seg.row + 1;
if (y < link.range.start.y || y > link.range.end.y) return false;
const from = y === link.range.start.y ? link.range.start.x : 1;
const to = y === link.range.end.y ? link.range.end.x : Number.MAX_SAFE_INTEGER;
return from <= seg.endCol && to >= seg.startCol + 1;
});
for (let i = links.length - 1; i >= 0; i--) {
if (overlapsClaim(links[i])) links.splice(i, 1);
}
for (const hit of cellOnlyPaths) {
for (const seg of hit.segments) {
links.push({
text: hit.path,
range: { start: { x: seg.startCol + 1, y: seg.row + 1 }, end: { x: seg.endCol, y: seg.row + 1 } },
decorations: { pointerCursor: true, underline: true },
activate() {
activateFilePath(hit.path);
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
}
}
}
if (links.length > 0) {
console.debug(
'[LinkProvider] Found links:',
@@ -2409,6 +2495,30 @@ Object.assign(CodemanApp.prototype, {
return provider;
},
/**
* xterm's handler for OSC 8 hyperlinks (a program's own `ESC ]8;;url` links),
* which take precedence over the text link provider. Routed through the same
* opener after confirming the real target (the label may hide it), and only
* for web URLs: any other scheme a program sends is ignored.
*/
_terminalOsc8LinkHandler() {
return {
activate: (_event, text) => {
if (!/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) return;
if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text);
},
};
},
/** Open a URL clicked in a terminal (text link or OSC 8). */
_openTerminalUrl(text) {
// A `localhost` link tapped from another device can only work through the
// server: route it into a proxied web tab (webview-tabs.js). Anything else
// opens in a new tab.
if (this.openLinkThroughWebTabIfLoopback?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
/**
* The terminal link under a viewport point, or null.
*
@@ -6556,12 +6666,124 @@ Object.assign(CodemanApp.prototype, {
// timer and the resize observer, where an exception takes the rest of the
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
try {
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
const dims = window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
// Rows held under the on-screen keyboard (see keyboardClipPx): the
// container shrank, the PTY must not. Columns still follow the container.
const held = this._keyboardHeldRowsActive?.();
if (dims && held) dims.rows = Math.max(dims.rows, held);
return dims;
} catch {
return null;
}
},
/**
* Rows held while the on-screen keyboard is up, or 0. Set by KeyboardHandler
* on the show transition when `keyboardHoldsRows` is on (default), cleared on
* hide and on a shape change. Every geometry read goes through
* getTerminalDimensions(), so holding it there holds the fit, the PTY resize
* and every capture-geometry comparison alike.
* @returns {number}
*/
_keyboardHeldRowsActive() {
if (!(this._keyboardHeldRows > 0)) return 0;
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible ? this._keyboardHeldRows : 0;
},
/**
* May the keyboard hold this session's rows? Only an INLINE renderer (the
* `keyboardHold` capability, read from `window.__codemanKeyboardHoldModes`,
* never an id literal here): a CLI that lays out the full height (codex) cannot
* show both its content and its composer above the keyboard and must be
* resized, as before. Never while the CLI reports mouse tracking (Claude's
* fullscreen mode), and never for a remote or docker session, whose pane hosts
* a nested client. Absent data means no hold. Per-device setting
* `keyboardHoldsRows`, default ON.
*/
_keyboardHoldEligible(sessionId = this.activeSessionId) {
if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false;
const session = sessionId ? this.sessions?.get(sessionId) : null;
if (!session || session.remote || session.docker || session.cliMouseTracking) return false;
const modes = window.__codemanKeyboardHoldModes;
return Array.isArray(modes) && modes.includes(session.mode || 'claude');
},
/**
* Drop a hold the active session no longer qualifies for (switched to a CLI
* that resizes, entered fullscreen, setting turned off) and, unless the caller
* resizes itself (selectSession), fit and tell the PTY at once, which is the
* old keyboard behavior. Never re-holds: a session that becomes eligible with
* the keyboard already up keeps the short rows until the keyboard closes.
*/
_reconcileKeyboardHold({ resize = true } = {}) {
if (!this._keyboardHeldRows || this._keyboardHoldEligible()) return;
this._releaseKeyboardHold();
if (resize && this.activeSessionId) this.sendResize(this.activeSessionId)?.catch?.(() => {});
},
/**
* End a keyboard row hold and put the screen back in place (no offset, no
* overlay bound). Shared by KeyboardHandler (keyboard hide, shape change)
* and _reconcileKeyboardHold.
* @returns {boolean} whether a hold was released
*/
_releaseKeyboardHold() {
if (!this._keyboardHeldRows) return false;
this._keyboardHeldRows = 0;
this._applyKeyboardClip();
return true;
},
/**
* Scroll the (held, taller than its box) terminal screen so the live content
* sits above the keyboard, and bound the local-echo overlay to the rows that
* are visible. Runs on every render and cursor move while the hold is active;
* resets once when it ends.
*/
_applyKeyboardClip() {
const container = this.terminal?.element?.parentElement;
if (!container) return;
const rows = this._keyboardHeldRowsActive() ? this.terminal.rows : 0;
const cellHeight = rows ? this.terminal._core?._renderService?.dimensions?.css?.cell?.height : 0;
const viewHeight = rows ? container.clientHeight : 0;
let clip = 0;
if (cellHeight && viewHeight) {
const buffer = this.terminal.buffer.active;
const following = buffer.viewportY >= buffer.baseY;
let lastContentRow = -1;
if (following) {
for (let row = rows - 1; row >= 0; row--) {
if (buffer.getLine(buffer.baseY + row)?.translateToString(true).trim()) {
lastContentRow = row;
break;
}
}
}
clip = window.CodemanTerminalGeometry.keyboardClipPx({
rows,
cellHeight,
viewHeight,
cursorRow: buffer.cursorY,
lastContentRow,
following,
viewportY: buffer.viewportY,
baseY: buffer.baseY,
});
}
if (clip !== (this._keyboardClipPx || 0)) {
this._keyboardClipPx = clip;
if (clip) container.style.setProperty('--kb-clip', `${clip}px`);
else container.style.removeProperty('--kb-clip');
}
// Bounded whenever rows are held, even at offset 0 (a prompt near the top
// still has held rows below it, under the keyboard). setRowLimit dedupes.
this._localEchoOverlay?.setRowLimit?.(
cellHeight && viewHeight
? window.CodemanTerminalGeometry.keyboardVisibleRowLimit(clip, viewHeight, cellHeight)
: null
);
},
/**
* Fit xterm to its container and return the geometry that was APPLIED.
*
@@ -6589,7 +6811,10 @@ Object.assign(CodemanApp.prototype, {
syncTerminalGeometry() {
if (!this.fitAddon || !this.terminal) return null;
try {
this.fitAddon.fit();
// fit() would shrink xterm to the keyboard-shortened box before the held
// rows put it back: skip it while they are held (_resizeTerminalTo below
// still applies the container's columns).
if (!this._keyboardHeldRowsActive?.()) this.fitAddon.fit();
} catch {
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
}
@@ -6614,8 +6839,9 @@ Object.assign(CodemanApp.prototype, {
* part of the scrollback in between. The mobile retry asks every 30 seconds,
* so that happened on a timer for as long as the refusal lasted. So the
* columns stay at the PTY's (the #464 invariant: the browser never draws at
* a width the PTY does not have), the rows follow the container (they are
* never adopted, see reconcilePtyGeometry), and the container's columns go
* a width the PTY does not have), the rows follow the container or the
* keyboard hold (they are never adopted, see reconcilePtyGeometry and
* getTerminalDimensions), and the container's columns go
* out as the request. An accepted request is adopted by the report.
*
* @returns {{cols: number, rows: number}|null} the geometry to request
+9 -9
View File
@@ -1,13 +1,13 @@
{
"version": "1.45.0",
"version": "1.48.0",
"highlights": [
"Rename a case and its folder (#680)",
"A per-session agent inbox (#690)",
"Pick the agent from Cmd+K (#685)",
"A resizable header toolbar (#689)",
"GPU and CPU clock readings (#692, opt-in)",
"A tunnel no longer locks everyone out after ten typos (#684, multi-user mode)",
"The first wheel notch moves a Codex transcript (#699)",
"The wheel no longer moves a Codex menu's selection (#698)"
"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)"
]
}
+59
View File
@@ -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;
});
}
+193
View File
@@ -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'
)
);
}
});
}
+156 -40
View File
@@ -51,6 +51,8 @@ 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 {
@@ -78,6 +80,12 @@ import {
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';
@@ -144,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
@@ -655,7 +667,8 @@ export function registerCaseRoutes(
* 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`).
@@ -680,7 +693,7 @@ export function registerCaseRoutes(
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);
@@ -699,8 +712,59 @@ export function registerCaseRoutes(
);
}
// 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
@@ -709,8 +773,14 @@ export function registerCaseRoutes(
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)
@@ -718,16 +788,47 @@ export function registerCaseRoutes(
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 } : {}),
@@ -788,6 +889,26 @@ export function registerCaseRoutes(
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,
@@ -1849,6 +1970,17 @@ export function registerCaseRoutes(
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.
@@ -1876,27 +2008,23 @@ export function registerCaseRoutes(
}
if (moveFolder) await moveCaseFolder(oldPath, newPath);
const originalLinks = isLinked ? await readLinkedCases() : undefined;
// The settings.json content before this rename wrote it, set only once a write is attempted (an
// absent file reads as {}, which must not be written back as a file that never existed).
let originalSettings: Record<string, unknown> | 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 existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (order || last) {
originalSettings = existing;
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(
{ ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) },
null,
2
)
);
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) {
@@ -1913,9 +2041,8 @@ export function registerCaseRoutes(
} 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.
if (originalSettings) {
await fs.writeFile(SETTINGS_PATH, JSON.stringify(originalSettings, null, 2)).catch(() => {});
}
const undo = settingsUndo;
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
if (moveFolder) {
const movedBack = await fs.rename(newPath, oldPath).then(
() => true,
@@ -1970,22 +2097,11 @@ export function registerCaseRoutes(
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);
}
});
+10 -3
View File
@@ -128,8 +128,8 @@ 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 a read
// that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30
// 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';
@@ -150,7 +150,12 @@ export async function runConfigBackup(
});
}
export function registerConfigBackupRoutes(app: FastifyInstance): void {
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);
@@ -236,6 +241,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
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({
+64 -9
View File
@@ -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';
@@ -158,9 +158,14 @@ 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 on the same loaded-models-only basis as `contextLengths`. */
/** 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>;
@@ -182,6 +187,20 @@ 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 `n_ctx` and `total_slots` out. Never called for a model that
@@ -312,25 +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')
@@ -364,11 +400,30 @@ async function discoverModels(
// merges onto what was learned before).
if (cmdSlots !== undefined && ctxFromProps) slots = 1;
}
if (ctx !== undefined) contextLengths[id] = ctx;
// 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;
}
}
}
// 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 };
}
+228
View File
@@ -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 };
});
}
+140
View File
@@ -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 } };
});
}
+89 -2
View File
@@ -57,6 +57,7 @@ import {
findSessionOrFail,
getAuthUser,
parseBody,
resolveCasesDir,
validateSessionFilePath,
validateSessionFilePathLexical,
} from '../route-helpers.js';
@@ -74,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,
@@ -767,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}`));
}
@@ -1444,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);
+83 -14
View File
@@ -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 };
});
}
+4
View File
@@ -16,6 +16,7 @@ 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';
@@ -35,6 +36,7 @@ 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,
@@ -47,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';
+57 -16
View File
@@ -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';
@@ -20,17 +20,23 @@ import {
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
@@ -191,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) {
@@ -227,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);
@@ -243,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}`);
}
});
@@ -257,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) {
@@ -470,18 +500,10 @@ export function registerRalphRoutes(
// in single-user mode.
if (isAdmin(req)) {
try {
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
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 */
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
} catch (err) {
// Non-critical: the loop starts regardless.
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
}
}
@@ -519,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');
}
+231 -83
View File
@@ -7,7 +7,7 @@
import { claudeProjectDirName } from '../../case-rename.js';
import { FastifyInstance, type FastifyRequest } from 'fastify';
import { z } from 'zod';
import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path';
import { join, dirname, extname, basename, isAbsolute, resolve as resolvePath } from 'node:path';
import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
@@ -35,6 +35,7 @@ import {
type RemoteHost,
} from '../../types.js';
import {
GuardedInputBusyError,
Session,
cliTakesSessionModel,
isAltScreenStripMode,
@@ -57,6 +58,7 @@ import {
AutoCompactSchema,
AutoResumeSchema,
KeepWarmSchema,
GitFetchSettingsSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
@@ -68,8 +70,9 @@ import {
SessionWaitOutputQuerySchema,
CustomModelSelectionSchema,
} from '../schemas.js';
import { readCustomModelHosts } from '../../custom-model-hosts.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../../custom-model-hosts.js';
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
import { buildCustomModelInjection } from '../../custom-model-injection.js';
import {
getLlamaSwapStatus,
triggerLlamaSwapLoad,
@@ -114,17 +117,17 @@ import {
resolveCasesDir,
resolveParentSessionId,
sessionCapacityMessage,
SETTINGS_PATH,
validatePathWithinBase,
waitCapacityResponse,
abortOnClientHangUp,
} from '../route-helpers.js';
import { mutateSettingsFile } from '../settings-writer.js';
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
import { agentInbox } from '../agent-inbox.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { AgentSkillDir, NewlineSequence } from '../../config/cli-registry/types.js';
import type { AgentSkillDir, CliEntry, NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { applyLaunchDefaults } from '../launch-defaults.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
@@ -228,6 +231,39 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId));
}
/**
* Guard a session-supplied configDir home BEFORE `applyCustomModelInjection` runs.
*
* The apply resolves the CLI's real home from this session's env override and then runs
* synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, and any future
* `linkFromRealHome` recipe). A path on an unreachable network mount makes a synchronous
* stat wait forever, and this runs INSIDE a request handler, so it would freeze the whole
* web server (CLAUDE.md, the #516 rule on user-chosen paths). A relative home is left to
* the apply, which already refuses to join non-absolute values.
*
* Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on
* `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that
* one env var, so linking falls through to the server's own environment and the unresponsive
* path is never touched. Any other entry's lookup passes through untouched.
*/
export async function guardSessionConfigDirHome(
entry: Pick<CliEntry, 'capabilities'>,
baseLookup: (envVar: string) => string | undefined
): Promise<(envVar: string) => string | undefined> {
const recipe = entry.capabilities.customModelInjection;
if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup;
const dirEnvVar = recipe.dirEnvVar;
const supplied = baseLookup(dirEnvVar)?.trim();
if (!supplied || !isAbsolute(supplied)) return baseLookup;
if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') {
console.warn(
`[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead`
);
return (envVar: string) => (envVar === dirEnvVar ? undefined : baseLookup(envVar));
}
return baseLookup;
}
/**
* The case a POST /api/sessions `workingDir` belongs to, for its default session
* group: a direct child of the caller's case space, or (admin / single-user, as
@@ -1539,9 +1575,36 @@ export function registerSessionRoutes(
if (!endpoint) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
}
const contextLength = endpoint.modelContextLengths?.[body.modelId];
// Discovery's per-model figure wins; the endpoint's user-set default covers models
// whose size discovery could not determine (a gateway that states none, say). With
// neither, the existing "no override, CLI assumes its own default" behaviour stands.
const contextLength = resolveCustomModelContextLength(endpoint, body.modelId);
const slotCount = endpoint.modelSlotCounts?.[body.modelId];
// A CLI whose config alone cannot select the model also gets its `model` launch param
// forced (pi/omp `custom/<id>`, grok's block name, and codex now carries its bare
// selected id too). The argv engine DROPS a token that
// fails its pattern rather than quoting it, which would silently launch the CLI on its
// own default provider again, so refuse an id the pattern cannot carry up front.
// The check runs on the PURE build result, before applyCustomModelInjection below:
// the forced launch id only comes out of that build, and writing first would mean a
// refused id already replaced the config dir a live codex session uses as CODEX_HOME.
// It also runs before the two confirmations, so nobody agrees to evict a model only
// to be told the id was never usable.
const modelSpec = entry.launch.params.model;
const injection = buildCustomModelInjection(entry, endpoint, body.modelId, contextLength);
const forcedLaunchModel = injection.kind === 'unsupported' ? undefined : injection.launchModel;
if (
forcedLaunchModel !== undefined &&
modelSpec?.type === 'token' &&
!matchesPattern(modelSpec.pattern, forcedLaunchModel)
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Model id ${JSON.stringify(body.modelId)} cannot be passed to ${session.mode} on its command line`
);
}
// Some CLIs (today: only claude) carry enough of their own fixed system-prompt/tool-
// schema overhead that a small enough real context guarantees a first-message failure
// no matter what CLAUDE_CODE_MAX_CONTEXT_TOKENS says — confirmed live at ~36.4K tokens
@@ -1605,33 +1668,18 @@ export function registerSessionRoutes(
}
}
// A CLI whose config alone cannot select the model also gets its `model` launch param
// forced (pi/omp `custom/<id>`, grok's block name). The argv engine DROPS a token that
// fails its pattern rather than quoting it, which would silently launch the CLI on its
// own default provider again, so refuse an id the pattern cannot carry up front.
const modelSpec = entry.launch.params.model;
const applied = applyCustomModelInjection(
entry,
endpoint,
body.modelId,
session.id,
contextLength,
session.workingDir
session.workingDir,
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
);
if (!applied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
}
if (
applied.launchModel !== undefined &&
modelSpec?.type === 'token' &&
!matchesPattern(modelSpec.pattern, applied.launchModel)
) {
removeConfigDir(applied.configDir);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Model id ${JSON.stringify(body.modelId)} cannot be passed to ${session.mode} on its command line`
);
}
const { previousConfigDir } = session.setCustomModel(
{
@@ -1681,7 +1729,8 @@ export function registerSessionRoutes(
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
// below re-looks-up the record by id, so the returned SessionState is unused here.
findPersistedSessionOrFail(ctx.store, id, req);
ctx.store.demoteOrRemoveSession(id);
// The edit history goes with the record (same rule as `cleanupSession`).
if (ctx.store.demoteOrRemoveSession(id) === 'removed') ctx.forgetEditHistory(id);
// A detached session kept its agent inbox (server.ts _doCleanupSession drops it
// only on killMux); retiring the row is the delete, so the mail goes now.
agentInbox.drop(id);
@@ -2077,6 +2126,32 @@ export function registerSessionRoutes(
}
}
// A guarded submit (agent-staging.ts) holds the input lease for the few
// milliseconds of its conditional Enter, and input that arrives meanwhile is held
// and written in arrival order after it. A TAGGED request is refused for that span
// (the lease plus the held input's drain) rather than overtake held input: say so
// instead of answering 200 for bytes that were never written, and give the seq
// back so the browser's durable queue (which keeps a frame on any 409, and whose
// POST drainer sends one frame at a time) delivers it on its next retry. An
// UNTAGGED request has no retry behind it (the phone accessory keys and the Stop
// button's Ctrl+C are fire-and-forget POSTs that never read the answer), so it is
// held instead, like a WebSocket frame: `writeViaMux` takes its own turn behind
// the lease, and the raw write below is held explicitly. Nothing from here to the
// write below awaits, so the lease cannot be taken in between.
const refuseGuarded = () =>
reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
'A guarded submit is in progress on this session; nothing was written. Retry the input.'
)
);
if (!duplicate && tagged && session.guardedInputHeld === true) {
session.forgetInputSeq(clientId as string, seq as number);
return refuseGuarded();
}
// Only a waiting request pays for the tmux probe: the browser's plain input path
// (thousands of calls per session) must stay exec-free.
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
@@ -2187,7 +2262,38 @@ export function registerSessionRoutes(
// Same rollback. NOT an error response, deliberately: a session can
// legitimately have no PTY yet (created but not started), and callers have
// always been able to write to one without a 4xx.
delivered = session.write(inputStr, { fromUser: true });
//
// During a guarded submit (untagged only: a tagged one was refused above) the
// write is held and runs in arrival order once the lease is released. The answer
// is a 200 once held; a send-and-wait reads `delivered` from the held write. Only
// a full hold queue refuses, after releasing the waiter registered above.
let heldWrite: Promise<boolean> | null = null;
if (session.guardedInputHeld === true) {
let settle!: (written: boolean) => void;
const written = new Promise<boolean>((resolve) => {
settle = resolve;
});
const held = session.holdForGuardedLease(
() => {
let ok = false;
try {
ok = session.write(inputStr, { fromUser: true });
} finally {
settle(ok);
}
},
inputStr.length,
inputStr
);
if (held === 'full') {
abort.abort();
stopDeathWatch();
return refuseGuarded();
}
if (held === 'held') heldWrite = written;
}
if (heldWrite && !waitPromise) return {};
delivered = heldWrite ? await heldWrite : session.write(inputStr, { fromUser: true });
if (!delivered && tagged) {
session.forgetInputSeq(clientId as string, seq as number);
}
@@ -2398,7 +2504,7 @@ export function registerSessionRoutes(
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
// 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req) => {
app.post('/api/sessions/:id/send-key', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
@@ -2428,18 +2534,26 @@ export function registerSessionRoutes(
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => {
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
});
await session.performAuxiliaryInput(
() =>
new Promise<void>((resolve, reject) => {
execFile(
'tmux',
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
{ timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
})
);
} catch (err) {
// A guarded submit's lease holds the key until its Enter is out; only a full
// hold queue refuses it, and that is a retryable conflict, not a tmux failure.
if (err instanceof GuardedInputBusyError) {
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, err.message));
}
console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
}
@@ -2807,16 +2921,14 @@ export function registerSessionRoutes(
return candidates[0] ?? null;
}
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
// The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript
// the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic.
const lastResponseFor = async (session: Session, context?: string) => {
// Codex sessions don't write to ~/.claude/projects — their transcripts
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
// response-viewer works for Codex panes too.
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
const codexQuery = req.query as { context?: string };
return await readCodexLastResponse(session, codexQuery.context === 'full');
return await readCodexLastResponse(session, context === 'full');
}
// DeepSeek Harness writes a real structured transcript under
@@ -2835,8 +2947,7 @@ export function registerSessionRoutes(
// would starve on an answer that exists. Those configurations keep the
// pane segmenter below: coarse, but the real conversation.
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const full = context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
if (transcript) {
return {
@@ -2854,7 +2965,6 @@ export function registerSessionRoutes(
// the pane IS the transcript for these CLIs. Codex is already handled above,
// where a real rollout file is the better source.
if (isExternalCliTranscriptMode(session.mode)) {
const externalQuery = req.query as { context?: string };
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
// buffer when there is no tmux pane to ask (direct PTY, tests).
@@ -2867,7 +2977,7 @@ export function registerSessionRoutes(
text: getLastTranscriptResponse(blocks),
timestamp: '',
hasContext: blocks.length > 0,
messages: externalQuery.context === 'full' ? blocks : undefined,
messages: context === 'full' ? blocks : undefined,
};
}
@@ -2893,11 +3003,10 @@ export function registerSessionRoutes(
}
}
const query = req.query as { context?: string };
// `turn` is the brief view's context: the last ANSWERED turn's assistant
// messages, so a multi-row answer is not reduced to its final row. `text`
// stays the last assistant row in every mode (agent pollers hash it).
const wantsMessages = query.context === 'full' || query.context === 'turn';
const wantsMessages = context === 'full' || context === 'turn';
const claudeSessionId = session.claudeSessionId || session.id;
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
if (!transcript) {
@@ -2918,7 +3027,7 @@ export function registerSessionRoutes(
try {
const content = await fs.readFile(transcript.path, 'utf8');
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
if (query.context === 'turn') {
if (context === 'turn') {
return {
text: parsed.text,
timestamp: parsed.timestamp,
@@ -2929,6 +3038,13 @@ export function registerSessionRoutes(
} catch {
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
}
};
ctx.lastResponseFor = lastResponseFor;
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
return lastResponseFor(session, (req.query as { context?: string }).context);
});
function isCodexInjectedContext(text: string): boolean {
@@ -3608,6 +3724,40 @@ export function registerSessionRoutes(
};
});
// ========== Git fetch on an interval ==========
// Per-session override for the fetch interval (src/git-fetch-scheduler.ts), which is what the Session
// Options rows say it is: two sessions on one folder can hold different choices, and that folder is
// fetched when the first of them is due. `null` on either field clears this session's choice so the
// app-wide setting applies again, and a field left out of the body leaves the other half as it was: the
// Session Options rows save on their own. Refused for a remote or Docker session, exactly as the Git
// panel excludes them: a container can write to the repository it mounts, so git there would run that
// repository's own configuration on the host.
app.post('/api/sessions/:id/git-fetch-settings', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions');
}
const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled;
const intervalSeconds =
'intervalSeconds' in body ? (body.intervalSeconds ?? undefined) : session.gitFetchIntervalSeconds;
session.setGitFetchSettings(enabled, intervalSeconds);
persistAndBroadcastSession(ctx, session);
return {
success: true,
data: {
gitFetchSettings: {
enabled: session.gitFetchEnabled ?? null,
intervalSeconds: session.gitFetchIntervalSeconds ?? null,
},
},
};
});
// ========== Auto-Resume (usage-limit pause) ==========
app.post('/api/sessions/:id/auto-resume', async (req) => {
@@ -3679,6 +3829,11 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// A remote session's workingDir is a path on the remote host; the watcher would watch it on the
// LOCAL disk (a missing folder, or a same-named local one whose images are not the session's).
if (body.enabled && session.remote) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions');
}
if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir);
@@ -4341,9 +4496,28 @@ export function registerSessionRoutes(
const cmHosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId);
if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
const cmContextLength = cmEndpoint.modelContextLengths?.[customModel.modelId];
// Per-model discovery figure first, user-set endpoint default second, nothing third
// (see the dedicated route's own comment above).
const cmContextLength = resolveCustomModelContextLength(cmEndpoint, customModel.modelId);
const cmSlotCount = cmEndpoint.modelSlotCounts?.[customModel.modelId];
// See the dedicated route: refused on the pure build, before either confirmation, so
// nobody agrees to evict a model for an id that could never launch, and before anything
// is written.
const cmModelSpec = cmEntry.launch.params.model;
const cmInjection = buildCustomModelInjection(cmEntry, cmEndpoint, customModel.modelId, cmContextLength);
const cmForcedLaunchModel = cmInjection.kind === 'unsupported' ? undefined : cmInjection.launchModel;
if (
cmForcedLaunchModel !== undefined &&
cmModelSpec?.type === 'token' &&
!matchesPattern(cmModelSpec.pattern, cmForcedLaunchModel)
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line`
);
}
// See the dedicated route's own comment for the full reasoning: some CLIs' own fixed
// overhead can exceed a small enough real context on the very first message,
// regardless of contextLengthVar. Warn before creating a session that's certain to
@@ -4408,24 +4582,12 @@ export function registerSessionRoutes(
customModel.modelId,
qsCustomModelSessionId,
cmContextLength,
workspacePath
workspacePath,
await guardSessionConfigDirHome(cmEntry, (envVar) => qsGatedEnvOverrides?.[envVar])
);
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
}
const cmModelSpec = cmEntry.launch.params.model;
if (
cmApplied.launchModel !== undefined &&
cmModelSpec?.type === 'token' &&
!matchesPattern(cmModelSpec.pattern, cmApplied.launchModel)
) {
removeConfigDir(cmApplied.configDir);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line`
);
}
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
qsCustomModelLaunchModel = cmApplied.launchModel;
@@ -4576,28 +4738,14 @@ export function registerSessionRoutes(
// 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, like
// PUT /api/settings). isAdmin is always true in single-user mode.
// Awaited and serialized with every other settings.json writer (mutateSettingsFile): the
// un-awaited write this replaced could land after the response, over a settings save.
if (isAdmin(req)) {
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
// Non-critical: the session is already running.
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
}
}
+96 -64
View File
@@ -15,7 +15,7 @@ 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, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
@@ -48,6 +48,7 @@ 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';
@@ -177,6 +178,43 @@ export function settingsForNonAdmin(settings: unknown): unknown {
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 & KeepWarmPort
@@ -194,7 +232,14 @@ 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 (req) => {
const status = ctx.tunnelManager.getStatus();
@@ -811,6 +856,7 @@ export function registerSystemRoutes(
// 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 };
});
@@ -1032,11 +1078,10 @@ export function registerSystemRoutes(
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
@@ -1063,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.
@@ -1152,9 +1184,10 @@ export function registerSystemRoutes(
// 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);
}
}
@@ -1182,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()) {
@@ -1196,7 +1243,7 @@ export function registerSystemRoutes(
return {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
@@ -1214,25 +1261,10 @@ export function registerSystemRoutes(
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);
}
});
+83 -45
View File
@@ -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' &&
+123 -2
View File
@@ -20,6 +20,7 @@ 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';
@@ -82,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(),
@@ -785,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. */
@@ -1488,6 +1524,19 @@ export const SettingsUpdateSchema = z
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
@@ -1544,6 +1593,13 @@ export const SettingsUpdateSchema = z
* 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
@@ -1554,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(),
/**
@@ -1928,6 +1984,15 @@ export const AutoResumeSchema = z.object({
/** `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(),
@@ -2185,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 */
@@ -2323,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
@@ -2346,11 +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(),
});
/**
@@ -2410,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();
+15
View File
@@ -102,6 +102,21 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.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\//,
];
/**
+356 -27
View File
@@ -45,6 +45,8 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import { DiscordBridge, screenTail } from '../discord-bridge.js';
import { isExternalCliTranscriptMode } from './response-viewer-transcript.js';
import { stopHostSensors } from '../host-sensors.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
@@ -80,8 +82,13 @@ import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment
import { folderGrants } from '../folder-listing.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts } from '../custom-model-hosts.js';
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js';
import {
applyCustomModelInjection,
customModelConfigDir,
removeConfigDir,
type AppliedCustomModel,
} from '../custom-model-injection-apply.js';
import type { CustomModelBookkeeping } from '../types/session.js';
import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
import {
@@ -100,6 +107,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readGitFetchSettings,
readKeepWarmSettingsOrThrow,
readPlanUsageTelemetryEnabled,
removeAgentSessionPreamble,
@@ -123,8 +131,15 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
import { fetchWorkspaceOnce } from '../git-fetch.js';
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
import { probePathKind } from '../utils/bounded-path-probe.js';
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { EditHistorySync } from './edit-history-sync.js';
import { EditHistoryStore } from '../edit-history-store.js';
import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -174,6 +189,7 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { mutateSettingsFile } from './settings-writer.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
@@ -194,6 +210,7 @@ import {
import { isMultiUserMode } from '../config/multiuser.js';
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { installEmptyApiPostParser } from './request-body-parser.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
import {
registerPushRoutes,
@@ -208,11 +225,13 @@ import {
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
registerEditHistoryRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
registerWorktreeRoutes,
registerSessionRoutes,
registerAgentSessionRoutes,
registerRespawnRoutes,
registerRalphRoutes,
registerPlanRoutes,
@@ -230,6 +249,7 @@ import {
registerConfigBackupRoutes,
runConfigBackup,
registerWebhookRoutes,
registerDiscordRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
@@ -293,6 +313,7 @@ import {
STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
CRON_TICK_INTERVAL,
GIT_FETCH_TICK_INTERVAL_MS,
} from '../config/server-timing.js';
/**
@@ -334,6 +355,24 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } {
export class WebServer extends EventEmitter {
private app: FastifyInstance;
private sessions: Map<string, Session> = new Map();
/**
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks;
* the main transcript is the one the transcript watcher follows (hook-reported path), or,
* before the first hook after a restart, a lookup under the session's claude config dir.
*/
private readonly editHistorySync = new EditHistorySync({
transcriptPath: (session) =>
resolveClaudeTranscript(
this.transcriptWatchers.get(session.id)?.getPath(),
[session.claudeSessionId || session.id, session.id],
session.claudeConfigDir
),
readSources: readClaudeEditSources,
probe: probeLocalPaths,
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
deleteStore: (sessionId) => EditHistoryStore.delete(sessionId),
sweepStores: (keep) => EditHistoryStore.sweep(keep),
});
private respawnControllers: Map<string, RespawnController> = new Map();
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
private runSummaryTrackers: Map<string, RunSummaryTracker> = new Map();
@@ -389,6 +428,37 @@ export class WebServer extends EventEmitter {
},
log: (message) => console.log(message),
});
// Git fetch on an interval (git-fetch-scheduler.ts): the due-tracking lives in the service; the server
// lends it the facts a decision needs and stores what it reports on the session. A field initializer for
// the same reason as the keep-warm one above, and the scheduler reads the app setting FRESH on every tick:
// that is what makes a live toggle take effect on the next tick, and what makes turning it off actually
// stop the fetching. Default OFF, so an install that never enabled it spawns nothing at all.
private readonly gitFetchScheduler = new GitFetchScheduler({
sessionIds: () => this.sessions.keys(),
getSession: (id) => this.sessions.get(id),
readSettings: readGitFetchSettings,
// The same Docker list the panel's Fetch button uses: the rule has to be one rule. It rejects on a list
// that cannot be read, and the tick then fetches nothing (fail closed).
dockerWorkspaces: defaultDockerWorkspaces,
// Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must
// never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched.
probePath: (path) => probePathKind(path),
// Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the
// tick is already fetching joins it rather than starting a second `git fetch` in the same clone, and the
// run that gets published is the one that ran, with its own trigger.
fetch: (cwd, opts, trigger) => fetchWorkspaceOnce(cwd, opts, trigger),
publish: (id, status) => {
const session = this.sessions.get(id);
if (!session?.setGitFetchStatus(status)) return;
// No new SSE event: the status rides the session state the page already listens for, and the Git
// panel polls on its own clock. It is written to disk with the rest of the record, but the restore
// copies only the fields its own config lists and this is not one of them (src/types/session.ts), so
// what a run found is the panel's truth for the life of the session, not across a restart.
this.persistSessionState(session);
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
},
log: (message) => console.log(message),
});
/**
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
@@ -466,12 +536,35 @@ export class WebServer extends EventEmitter {
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
/** Two-way Discord bridge (thread per tab); config in discord.json (0600). Idle until enabled in Settings. */
/** Set by the session routes when they register (see SessionPort). */
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
private discordBridge = new DiscordBridge({
configDir: getDataDir(),
fetchImpl: webviewFetch,
sendInput: async (sessionId, text) => {
const session = this.sessions.get(sessionId);
if (!session) return false;
// A Discord reply is a person's own input, so it names the tab like any other prompt.
if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true;
return session.write(text, { fromUser: true });
},
agentExited: (id) => !!this.sessions.get(id)?.paneExit,
awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
// Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go.
sessionDir: (sessionId) => {
const session = this.sessions.get(sessionId);
return session && !session.remote ? session.workingDir : undefined;
},
});
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
private windowTitle: string;
private readonly indexHtmlTemplate: string;
private readonly allowUnauthenticatedNetwork: boolean;
/** `--allow-unauthenticated-tunnel`; the tunnel guard reads it through the route context (#706). */
private readonly allowUnauthenticatedTunnel: boolean;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
/** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */
@@ -489,7 +582,8 @@ export class WebServer extends EventEmitter {
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
basePath: string = '',
allowUnauthenticatedTunnel: boolean = false
) {
super();
this.setMaxListeners(0);
@@ -501,6 +595,7 @@ export class WebServer extends EventEmitter {
this.testMode = testMode;
this.allowUnauthenticatedNetwork =
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
this.allowUnauthenticatedTunnel = allowUnauthenticatedTunnel;
this.titleHostname = titleHostname || getHostname();
this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
@@ -829,6 +924,7 @@ export class WebServer extends EventEmitter {
addSession: this.registerSessionWithLayout.bind(this),
tabLayouts: this.tabLayouts,
cleanupSession: this.cleanupSession.bind(this),
forgetEditHistory: this.forgetEditHistory.bind(this),
setupSessionListeners: this.setupSessionListeners.bind(this),
persistSessionState: this.persistSessionState.bind(this),
persistSessionStateNow: this._persistSessionStateNow.bind(this),
@@ -880,6 +976,7 @@ export class WebServer extends EventEmitter {
scheduledRuns: this.scheduledRuns,
teamWatcher: this.teamWatcher,
tunnelManager: this.tunnelManager,
allowUnauthenticatedTunnel: this.allowUnauthenticatedTunnel,
pushStore: this.pushStore,
startScheduledRun: this.startScheduledRun.bind(this),
stopScheduledRun: this.stopScheduledRun.bind(this),
@@ -970,6 +1067,9 @@ export class WebServer extends EventEmitter {
this.userIpFailures = authState.userIpFailures;
}
// Bodyless API POSTs may arrive as empty chunked streams through a proxy.
installEmptyApiPostParser(this.app);
// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);
@@ -1242,6 +1342,22 @@ export class WebServer extends EventEmitter {
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
registerEditHistoryRoutes(this.app, ctx, this.editHistorySync);
// Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in
// tests for the same reason as the polls below: a test drives `tick()` directly instead of waiting on
// a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning
// it on takes effect on the next tick and turning it off actually stops the fetching.
if (!this.testMode) {
this.cleanup.setInterval(
() => {
this.gitFetchScheduler.tick().catch((err) => {
console.error('[git fetch] interval tick failed:', getErrorMessage(err));
});
},
GIT_FETCH_TICK_INTERVAL_MS,
{ description: 'git fetch interval checker' }
);
}
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
@@ -1249,6 +1365,9 @@ export class WebServer extends EventEmitter {
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
// The routes' ctx is a separate object: take the last-response reader they registered on it (Discord alerts).
this.lastResponseFor = (ctx as { lastResponseFor?: WebServer['lastResponseFor'] }).lastResponseFor;
registerAgentSessionRoutes(this.app, ctx);
registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx);
@@ -1260,12 +1379,23 @@ export class WebServer extends EventEmitter {
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerConfigBackupRoutes(this.app);
registerConfigBackupRoutes(this.app, { reloadDiscord: () => this.discordBridge.reload() });
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
registerDiscordRoutes(this.app, {
bridge: this.discordBridge,
configDir: getDataDir(),
ctx,
hostTitle: () => this.windowTitle,
});
if (!this.testMode) {
// The gateway waits for restoreMuxSessions(): a reply during restore would be told the tab is gone.
this.discordBridge.hold();
void this.discordBridge.ensureLoaded().catch(() => undefined);
}
registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
@@ -1390,7 +1520,7 @@ export class WebServer extends EventEmitter {
private async _rebuildCustomModelEnv(
session: Session,
saved: CustomModelBookkeeping
): Promise<Record<string, string> | undefined> {
): Promise<AppliedCustomModel | undefined> {
const entry = getCli(session.mode);
if (!entry) return undefined;
const endpoint = (await readCustomModelHosts(getDataDir())).find((h) => h.id === saved.endpointId);
@@ -1401,19 +1531,56 @@ export class WebServer extends EventEmitter {
return undefined;
}
try {
// Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that
// already fired once) and the session-env real-home lookup: the injected VALUES are
// never persisted, so after a real restart the session can no longer carry its
// allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first
// apply in-process. Real-home resolution therefore falls back to the SERVER's
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
return applyCustomModelInjection(
entry,
endpoint,
saved.modelId,
session.id,
endpoint.modelContextLengths?.[saved.modelId]
)?.envOverrides;
resolveCustomModelContextLength(endpoint, saved.modelId)
);
} catch (err) {
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
return undefined;
}
}
/**
* Restore one persisted custom-model selection (both boot-recovery paths). The rebuilt
* bookkeeping must come from the CURRENT recipe, not from the saved record: this is the
* first feature where a shipped recipe changed KIND (deepseek: env redirect -> per-session
* patch home), so a selection saved by an old build restores with e.g. no `configDir`
* while the new recipe writes one, and clearing it later could then never delete the
* generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
* still carry `setenv`s from both, and a clear has to unset every one.
*/
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
const applied = await this._rebuildCustomModelEnv(session, saved);
if (!applied) {
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
// is what a later clear needs.
session.setCustomModel(saved);
return;
}
session.setCustomModel(
{
...saved,
// Prefer the current recipe's dir; keep the saved one when the recipe writes none,
// so a clear can still delete a directory an older recipe created.
configDir: applied.configDir ?? saved.configDir,
launchModel: applied.launchModel,
envKeys: [...new Set([...saved.envKeys, ...applied.envKeys])],
},
applied.envOverrides
);
}
/** Persists full session state including respawn config to state.json */
private _persistSessionStateNow(session: Session): void {
// See session-manager.updateSessionState: __envOverrides is an internal disk-only
@@ -1499,6 +1666,9 @@ export class WebServer extends EventEmitter {
): Promise<void> {
const session = this.sessions.get(sessionId);
this.keepWarmService.remove(sessionId);
// A closed session takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id
// would still count as tracked, and its next run would land on a session that no longer exists.
this.gitFetchScheduler.remove(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
@@ -1685,7 +1855,8 @@ export class WebServer extends EventEmitter {
// Only remove from state.json if we're also killing the mux session.
// When killMux=false (server shutdown), preserve state for recovery.
if (killMux) {
this.store.demoteOrRemoveSession(sessionId);
// The edit history goes with the record; a pinned session's demoted record keeps it.
if (this.store.demoteOrRemoveSession(sessionId) !== 'preserved') this.forgetEditHistory(sessionId);
}
}
@@ -1700,7 +1871,11 @@ export class WebServer extends EventEmitter {
// detach: mail posted before that is still there when the session is re-attached
// (while detached, the routes answer 404, so nothing new arrives). Before
// cancelAll, so a reader released by it reads an empty inbox.
if (killMux) agentInbox.drop(sessionId);
if (killMux) {
agentInbox.drop(sessionId);
// A real delete only: a detach (killMux=false) leaves the session running and re-attachable.
void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined);
}
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
@@ -1914,6 +2089,16 @@ export class WebServer extends EventEmitter {
'</head>',
() => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>`
);
// Which run modes keep their PTY rows under a phone keyboard (inline renderers,
// the `keyboardHold` capability; `_keyboardHoldEligible` in terminal-ui.js). Same
// shape and reasoning as the page-key list above.
const keyboardHoldClis = enabledClis()
.filter((entry) => entry.capabilities.keyboardHold === 'inline')
.map((entry) => entry.id);
html = html.replace(
'</head>',
() => `<script>window.__codemanKeyboardHoldModes=${JSON.stringify(keyboardHoldClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
@@ -1989,13 +2174,23 @@ export class WebServer extends EventEmitter {
this.runSummaryTrackers.set(session.id, summaryTracker);
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs).
// A remote session's workingDir is a path on the remote host, and the watcher reads and
// fs.watch()es it on the LOCAL disk, so a remote session only records the path: the fix plan stays
// authoritative as for a local session (keeping the output-based completion guessers off), and its
// file is read over ssh only on an explicit import (fix-plan/read).
if (!isExternalCliMode(session.mode)) {
session.ralphTracker.setWorkingDir(session.workingDir);
if (session.remote) {
session.ralphTracker.setRemoteWorkingDir(session.workingDir);
} else {
session.ralphTracker.setWorkingDir(session.workingDir);
}
}
// Start watching for new images in this session's working directory (if enabled globally and per-session)
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
// Start watching for new images in this session's working directory (if enabled globally and per-session).
// A remote session's workingDir is a path on the remote host, and the watcher watches it on the LOCAL
// disk, so it is skipped there (the per-session and app-wide toggles skip it too).
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote) {
imageWatcher.watchSession(session.id, session.workingDir);
}
@@ -2503,10 +2698,27 @@ export class WebServer extends EventEmitter {
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
this.forgetEditHistory(s.id);
}
// History files no session owns (a crash between removing a record and deleting its file).
const exists = (id: string) => this.sessions.has(id) || !!this.store.getSession(id);
await this.editHistorySync.sweep(exists).catch((err) => {
console.warn('[EditHistory] Sweeping orphaned histories failed:', getErrorMessage(err));
});
return result.count;
}
/**
* Delete a session's edit history (diffs can hold secrets) once its record is gone: deleted
* live (`cleanupSession`), deleted as a record only (DELETE route), or cleaned as stale
* (startup, `POST /api/cleanup-state`).
*/
private forgetEditHistory(sessionId: string): void {
void this.editHistorySync.remove(sessionId).catch((err) => {
console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err));
});
}
/**
* Get lightweight state for SSE init - excludes full terminal buffers
* to prevent browser freezes. Terminal buffers are fetched on-demand.
@@ -2612,6 +2824,23 @@ export class WebServer extends EventEmitter {
// Multi-user: derive an ownership routing hint so an event only reaches the
// clients entitled to it (no-op in single-user — hint stays undefined).
this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined);
this.bridgeSessionEvent(event, data);
}
/** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */
private bridgeSessionEvent(event: string, data: unknown): void {
// Deletion is NOT handled here: `session:deleted` also fires for a detach (killMux=false), where the
// session keeps running and can be re-attached. The real delete calls the bridge from the cleanup path.
if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated) return;
const d = (data ?? {}) as { id?: string; name?: string; session?: { id?: string; name?: string } };
const s = d.session ?? d;
if (!s.id) return;
const id = s.id;
const run =
event === SseEvent.SessionCreated
? this.discordBridge.sessionCreated(id, s.name).then(() => this.discordBridge.sessionRenamed(id, s.name))
: this.discordBridge.sessionRenamed(id, s.name);
void run.catch(() => undefined);
}
/**
@@ -2725,13 +2954,24 @@ export class WebServer extends EventEmitter {
private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
if (!getPaneExit) return;
const getPaneCurrentPath = this.mux.getPaneCurrentPath?.bind(this.mux);
for (const session of this.sessions.values()) {
const muxName = session.muxName;
// No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
// for such a session anyway.
if (!muxName) continue;
if (!session.setPaneExit(getPaneExit(muxName))) continue;
this.persistSessionState(session);
// The pane's live directory rides the same read: it is the base a relative
// path printed in the terminal resolves against. The manager's answer is
// applied as given, `undefined` included: it keeps its map across a failed
// or empty read and carries a blank `#{pane_current_path}` forward, so an
// `undefined` here means an authoritative read listed no single directory
// for the session (split into several panes, or gone from the socket).
const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
if (session.setPaneExit(getPaneExit(muxName))) {
this.persistSessionState(session);
} else if (!paneCwdChanged) {
continue;
}
this.broadcastSessionStateDebounced(session.id);
}
this.closeCleanlyExitedSessions();
@@ -2847,6 +3087,27 @@ export class WebServer extends EventEmitter {
})
.catch(() => undefined);
// Discord: same event, same independence from Web Push. It adds the tail of what the CLI printed,
// so the person reading the thread can see what is being asked without opening the tab.
const dcSession = sessionId ? this.sessions.get(sessionId) : undefined;
// A question or permission dialog lives on the screen, so it keeps the screen tail (its options are
// there); a finished turn posts the CLI's whole last message instead, from its transcript.
const dialogEvent = event === SseEvent.HookPermissionPrompt || event === SseEvent.HookElicitationDialog;
void this.discordBridge
.notify({
event,
title: template.title,
body,
urgency: template.urgency as WebhookUrgency,
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
// A function, so the screen is only read (a synchronous tmux exec) once the bridge has decided to post.
tail: dcSession ? () => this.discordTail(dcSession, !dialogEvent) : undefined,
fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined,
host: this.windowTitle,
})
.catch(() => undefined);
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
@@ -2928,6 +3189,34 @@ export class WebServer extends EventEmitter {
}
}
/**
* What the person would see on the tab right now, for a Discord alert: the rendered tmux screen
* (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and
* reads as garbage. A session with no pane falls back to its plain text output, else nothing.
*/
private discordTail(session: Session, cutComposer: boolean): string {
const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null;
// The composer row and the status lines under it are screen chrome, not what the CLI said.
const glyph = cutComposer ? getCli(session.mode)?.capabilities.workDetect?.promptGlyph : undefined;
return screenTail(pane ?? session.textOutput ?? '', 14, 900, glyph);
}
/**
* The CLI's whole last message from the response viewer's reader (the CLI's own transcript, or the pane
* segmented into turns for the CLIs that keep none); undefined for a shell, which has no turns, and the
* screen tail stands in whenever the reader finds nothing.
*/
private async discordFullText(session: Session): Promise<{ text: string; version?: string } | undefined> {
if (!this.lastResponseFor) return undefined;
// A CLI with a transcript reader, or whose pane the viewer segments into turns; a shell has neither.
if (getCli(session.mode)?.capabilities.transcript === 'none' && !isExternalCliTranscriptMode(session.mode)) {
return undefined;
}
const r = await this.lastResponseFor(session);
// The turn it came from: the transcript's stamp, else when the person last submitted (a pane has no stamp).
return { text: r.text, version: r.timestamp || String(session.lastSubmitAt ?? '') };
}
/** The notification body for an event (shared by Web Push and the webhook channel). */
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
let body = sessionName ? `[${sessionName}]` : '';
@@ -3069,6 +3358,20 @@ export class WebServer extends EventEmitter {
if (restoredMail > 0) console.log(`[AgentInbox] restored ${restoredMail} pending message(s)`);
const restored = await this.restoreMuxSessions();
await this.finalizeRestoredState(restored);
void this.discordBridge
.release()
.then(async () => {
// Only after a successful restore, like the agent inbox prune and the tab layout reconciliation, and
// with the same keep set: a reboot-restore rebuild reuses the old id.
if (!restored) return;
await this.discordBridge.prune([
...this.sessions.keys(),
...Object.keys(this.store.getSessions()),
...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId),
]);
})
.then(() => this.discordBridge.startStaleSweep())
.catch(() => undefined);
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
// THIS instance whose case is gone from docker-cases.json (best-effort, never
@@ -3346,16 +3649,12 @@ export class WebServer extends EventEmitter {
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
if (await this.isTunnelEnabled()) {
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
settings.tunnelEnabled = false;
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
} catch {
/* ignore */
await mutateSettingsFile((settings) => ({ ...settings, tunnelEnabled: false }));
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
} catch (err) {
console.warn('Could not reset the Cloudflare tunnel setting:', getErrorMessage(err));
}
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
}
// Start team watcher for agent team awareness (always on — lightweight polling)
@@ -3521,7 +3820,7 @@ export class WebServer extends EventEmitter {
// bookkeeping survives a restart and the values are re-derived here.
const savedCustomModel = (saved as { __customModel?: CustomModelBookkeeping }).__customModel;
if (savedCustomModel) {
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
await this._restoreCustomModel(session, savedCustomModel);
}
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
@@ -3540,6 +3839,9 @@ export class WebServer extends EventEmitter {
if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) {
session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold);
}
if (saved.gitFetchEnabled !== undefined || saved.gitFetchIntervalSeconds !== undefined) {
session.setGitFetchSettings(saved.gitFetchEnabled, saved.gitFetchIntervalSeconds);
}
if (saved.autoResumeEnabled) {
// The stamp is re-armed by default, because a Codeman restart leaves the
// limit footer un-reprinted and dropping it there would strand the pause.
@@ -3654,6 +3956,9 @@ export class WebServer extends EventEmitter {
} catch (err) {
console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
}
// Per-tab bridge state only: a reboot-restore rebuild reuses the old id and stays on offer for a
// retry, so its thread must survive the discard.
this.discordBridge.forgetTab(sessionId);
// Any `session:updated` the half-built session emitted before it failed left a
// tab on every other open board, and the client's handler is an upsert.
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
@@ -3762,6 +4067,11 @@ export class WebServer extends EventEmitter {
// below, a future `expiresAt` re-arms the timer once the pane is attached.
lastRealPromptAt: savedState?.lastRealPromptAt,
keepWarmEnabled: savedState?.keepWarmEnabled,
// The session's own fetch switch and interval, carried over rather than reset to the app default:
// the choice is the user's and outlives the run that made it. The scheduler reads them fresh
// on its next tick, so a restored session is back on its own schedule without a restart.
gitFetchEnabled: savedState?.gitFetchEnabled,
gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds,
// Conversations this pane provably owned, oldest first. Its tail is
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
@@ -3815,7 +4125,7 @@ export class WebServer extends EventEmitter {
// the isolated config dir; an endpoint that has since been deleted still gets
// the bookkeeping restored, which is what a later clear needs to unset.
if (savedCustomModel) {
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
await this._restoreCustomModel(session, savedCustomModel);
}
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
@@ -4221,6 +4531,9 @@ export class WebServer extends EventEmitter {
// only by the periodic idle sweep, whose interval is disposed just below.
closeAllLlamaSwapLogTails();
// Close the Discord gateway socket and its heartbeat, and clear the bridge's per-tab maps.
this.discordBridge.stop();
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
@@ -4233,6 +4546,9 @@ export class WebServer extends EventEmitter {
sessionWaits.cancelEverything();
approvalInbox.stop();
this.keepWarmService.stop();
// Stops the fetch sweep from starting another run while the server is closing; a git process already
// running is left to finish, which is what a fetch does to a repository nothing else is touching.
this.gitFetchScheduler.stop();
// A post within the debounce window before shutdown would otherwise not reach disk.
// Flushed again after `app.close()` below, for a post that lands in between.
agentInboxPersistence.flush();
@@ -4348,6 +4664,9 @@ export class WebServer extends EventEmitter {
this.respawnTimers.clear();
this.runSummaryTrackers.clear();
this.transcriptWatchers.clear();
await this.editHistorySync.flushAll().catch((err) => {
console.warn('[EditHistory] Saving on shutdown failed:', getErrorMessage(err));
});
this.sessionListenerRefs.clear();
this.scheduledRuns.clear();
// Dispose StaleExpirationMaps (stops internal cleanup timers)
@@ -4399,9 +4718,19 @@ export async function startWebServer(
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
basePath: string = '',
allowUnauthenticatedTunnel: boolean = false
): Promise<WebServer> {
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath);
const server = new WebServer(
port,
https,
testMode,
host,
titleHostname,
allowUnauthenticatedNetwork,
basePath,
allowUnauthenticatedTunnel
);
await server.start();
return server;
}
+118
View File
@@ -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));
}