mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
merge master (1.49.0 and later) into the MacBook test branch
One conflict, in install.sh's help text: both sides extended the same line of environment variables. Kept both additions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
fec052f402
181 files changed
+14849
-872
No files matched your search
+4
-2
@@ -22,7 +22,7 @@
|
||||
|
||||
**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)
|
||||
**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. ⚠️ **Right-to-left text** (#715) has three parts on that one pipeline: `_renderMarkdown` pins `pre, code` to `dir="ltr"`, `_linkifyFilePaths` pins the anchors it builds the same way (`dir="auto"` skips a descendant that carries a `dir`, so `npm install` or a path at the head of a Persian sentence does not decide its direction; a markdown link's label is prose and stays unpinned), and `_applyBlockDirection()` gives each OUTERMOST prose block `dir="auto"`, keeping a `dir` the agent wrote and what it covers. ⚠️ That pass runs LAST, on the DOM, in both consumers (`_buildResponseViewerMessage`, `_renderFilePreviewText`), never in `_renderMarkdown` (its string must hold no `dir="auto"`) and never inside the linkifier (which returns early on a stale constants.js): an element already carrying `dir="auto"` re-resolves its direction on every insertion under it, so a code-only or path-only document went quadratic and a 200 KB list froze the tab for 40 s. ⚠️ The `.rv-text` block rules use logical properties (`padding-inline-start`, `border-inline-start`, `border-inline-end`, `text-align: start`, logical corner radii), never physical left and right, or an RTL block keeps its bullets and quote bar on the wrong side. The terminal pane is out of reach: xterm.js has no RTL support (xtermjs/xterm.js#701). Tests: `test/file-preview-markdown.test.ts`, `test/response-viewer-rtl.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
|
||||
|
||||
@@ -46,6 +46,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail)
|
||||
|
||||
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
@@ -72,7 +74,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. The textarea carries `dir="auto"`, so a prompt written in Persian, Arabic or Hebrew lays out right to left from its first letter (#715). Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
|
||||
@@ -31,11 +31,15 @@
|
||||
* 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.
|
||||
* WHICH SESSIONS. Claude sessions, local and remote. Docker and non-claude sessions answer
|
||||
* `unsupported` and are never read. The deps get the session and read a remote one over ssh
|
||||
* (`remote-files.ts`): one round trip to find its transcript, one per read (more for a
|
||||
* conversation with hundreds of subagents). A remote read never wakes its host; a sleeping one
|
||||
* is a connect timeout (`unavailable`, stored entries kept).
|
||||
*
|
||||
* 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.
|
||||
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing. Transcript times and mtimes come
|
||||
* from the same host, so a remote host's clock skew to this server does not matter.
|
||||
*
|
||||
* Tests: `test/edit-history-sync.test.ts`.
|
||||
*
|
||||
@@ -75,21 +79,30 @@ export interface EditHistorySession {
|
||||
mode: string;
|
||||
remote: SessionRemote | undefined;
|
||||
docker?: unknown;
|
||||
/** Set on a wrapper around a foreign tmux session: Codeman holds no transcript id for what runs there. */
|
||||
adopt?: 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>;
|
||||
/**
|
||||
* The session's main transcript path, or null when it has none yet. `storedTranscriptId` is
|
||||
* the conversation the stored history holds (null before the first read); see
|
||||
* {@link remoteTranscriptIds}.
|
||||
*/
|
||||
transcriptPath(session: EditHistorySession, storedTranscriptId: string | null): Promise<string | null>;
|
||||
/** The session's transcripts (a remote session's on its host). */
|
||||
readSources(
|
||||
session: EditHistorySession,
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number,
|
||||
opts?: EditSourceReadOptions
|
||||
): Promise<EditSources | null>;
|
||||
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
|
||||
/** Bash image candidates of the session, on the host it runs on. */
|
||||
probe(session: EditHistorySession, 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>;
|
||||
@@ -102,10 +115,23 @@ export interface SyncOptions {
|
||||
older?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation ids a REMOTE session's transcript lookup considers when its claude writes
|
||||
* none now; the most recently written of them wins (`buildRemoteClaudeTranscriptLocateCommand`).
|
||||
* The stored history's conversation must be among them: no hook tells Codeman of a remote
|
||||
* `/clear`, so `claudeSessionId` stays the launch id, and without the stored one the panel
|
||||
* would flip back to the cleared conversation as soon as the remote claude exits.
|
||||
*/
|
||||
export function remoteTranscriptIds(session: EditHistorySession, storedTranscriptId: string | null): string[] {
|
||||
return [...new Set([storedTranscriptId, session.claudeSessionId || session.id, session.id])].filter(
|
||||
(id): id is string => !!id
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (session.adopt) return 'adopted';
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
|
||||
return undefined;
|
||||
}
|
||||
@@ -324,16 +350,16 @@ export class EditHistorySync {
|
||||
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);
|
||||
const path = slot.gone ? null : await this.deps.transcriptPath(session, store.getTranscriptId());
|
||||
let found: boolean;
|
||||
if (!path) {
|
||||
found = false;
|
||||
} else if (older) {
|
||||
found = await this.readOlder(path, store, slot);
|
||||
found = await this.readOlder(session, 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);
|
||||
const r = await this.readForward(session, path, store);
|
||||
found = r.found;
|
||||
if (!r.more) break;
|
||||
}
|
||||
@@ -359,13 +385,17 @@ export class EditHistorySync {
|
||||
* 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 }> {
|
||||
private async readForward(
|
||||
session: EditHistorySession,
|
||||
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));
|
||||
const previous = await this.readFrom(session, own, store, this.forwardRequests(store));
|
||||
if (previous.more) return { found: true, more: true };
|
||||
}
|
||||
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
|
||||
return this.readFrom(session, path, store, own === path ? this.forwardRequests(store) : []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,11 +408,12 @@ export class EditHistorySync {
|
||||
}
|
||||
|
||||
private async readFrom(
|
||||
session: EditHistorySession,
|
||||
path: string,
|
||||
store: EditHistoryStore,
|
||||
requests: EditSourceRequest[]
|
||||
): Promise<{ found: boolean; more: boolean }> {
|
||||
const res = await this.deps.readSources(path, requests, this.budget);
|
||||
const res = await this.deps.readSources(session, 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)) {
|
||||
@@ -413,11 +444,16 @@ export class EditHistorySync {
|
||||
candidates.push(...r.candidates);
|
||||
if (f.startOffset + f.chunk.length < f.size) more = true;
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
await this.addImages(session, store, candidates);
|
||||
return { found: true, more };
|
||||
}
|
||||
|
||||
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
|
||||
private async readOlder(
|
||||
session: EditHistorySession,
|
||||
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);
|
||||
@@ -440,7 +476,9 @@ export class EditHistorySync {
|
||||
}
|
||||
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 });
|
||||
const res = await this.deps.readSources(session, path, requests, this.budget * requests.length, {
|
||||
onlyRequested: true,
|
||||
});
|
||||
if (!res) return false;
|
||||
if (res.transcriptId !== store.getTranscriptId()) return true;
|
||||
this.dropMissing(store, requests, res);
|
||||
@@ -462,7 +500,7 @@ export class EditHistorySync {
|
||||
});
|
||||
candidates.push(...r.candidates);
|
||||
}
|
||||
await this.addImages(store, candidates);
|
||||
await this.addImages(session, store, candidates);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -472,13 +510,17 @@ export class EditHistorySync {
|
||||
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
|
||||
}
|
||||
|
||||
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
|
||||
private async addImages(
|
||||
session: EditHistorySession,
|
||||
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);
|
||||
probes = await this.deps.probe(session, paths);
|
||||
} catch {
|
||||
// The edits of this read are in; only its images are lost. The read itself succeeded.
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
|
||||
*
|
||||
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
|
||||
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
|
||||
* only codex: `model`, `reasoningEffort` and `networkAccess`), and
|
||||
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
|
||||
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
|
||||
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
|
||||
@@ -39,7 +39,7 @@ export async function applyLaunchDefaults<T extends object>(
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const [param, settingKey] of Object.entries(declared)) {
|
||||
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
|
||||
// '' is the settings' "leave it to the CLI" value, the same as unset.
|
||||
// '' and an off switch are the settings' "leave it to the CLI" value, the same as unset.
|
||||
const value = parsed.success ? parsed.data || undefined : undefined;
|
||||
const wireKey = aliases[param] ?? param;
|
||||
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @fileoverview The writer of the linked-cases registry (`linked-cases.json` in the data dir): case
|
||||
* name to the absolute folder it stands for.
|
||||
*
|
||||
* Every read-modify-write of that file goes through `mutateLinkedCases()`, the same shape as
|
||||
* `mutateSettingsFile()` (settings-writer.ts). The one direct writer is config-backup restore
|
||||
* (`restoreConfigBackup()`), which must work on a file that does not parse. Readers keep reading the
|
||||
* file directly; a write is atomic, so they never see half of one.
|
||||
*
|
||||
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, changing
|
||||
* and writing before the next starts. Link, unlink, rename, a create in a custom folder and a clone
|
||||
* into one each used to read the file, check it and write it back on their own. Two clones that
|
||||
* claimed one name and finished together both passed the check, both answered success, and the
|
||||
* later write replaced the earlier one's entry. Any two writers could lose an entry the same way.
|
||||
* A check that must hold when the entry is written belongs inside `fn`: throwing there rejects that
|
||||
* caller only, and nothing is written.
|
||||
* - **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 (quick-start would read one as an empty registry and resolve
|
||||
* a linked case to the cases dir instead), and a failed write leaves the previous file in place.
|
||||
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file that
|
||||
* cannot be read or is not a JSON object is left alone and the write throws
|
||||
* `LinkedCasesWriteRefusedError`: read as `{}`, the next link would write back its own entry alone
|
||||
* and unlink every other case.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { getErrorMessage } from '../types.js';
|
||||
|
||||
export type LinkedCases = Record<string, string>;
|
||||
|
||||
/** Resolved at call time, like every other `dataPath()` caller that tests point at a temp HOME. */
|
||||
export function linkedCasesPath(): string {
|
||||
return dataPath('linked-cases.json');
|
||||
}
|
||||
|
||||
/** A write refused because the existing registry must not be overwritten. The message is user-facing. */
|
||||
export class LinkedCasesWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LinkedCasesWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readLinkedCasesForWrite(path: string): Promise<LinkedCases> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||
throw new LinkedCasesWriteRefusedError(
|
||||
`Cannot read ${path} (${(err as Error).message}), so the linked cases were not changed.`
|
||||
);
|
||||
}
|
||||
if (raw.trim() === '') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new LinkedCasesWriteRefusedError(
|
||||
`${path} is not a valid JSON object, so the linked cases were not changed (saving would have unlinked every other case). Fix or delete the file, or restore it from a config backup, then try again.`
|
||||
);
|
||||
}
|
||||
return parsed as LinkedCases;
|
||||
}
|
||||
|
||||
async function writeLinkedCasesFile(path: string, linked: LinkedCases): 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 });
|
||||
// A plain writeFile kept the file's permissions; a rename would not, so the temp file takes them.
|
||||
const mode = await fs.stat(target).then(
|
||||
(s) => s.mode & 0o777,
|
||||
() => undefined
|
||||
);
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(linked, null, 2), mode === undefined ? undefined : { mode });
|
||||
// The creation mode is masked by the umask; the existing file's bits are put back as they were.
|
||||
if (mode !== undefined) await fs.chmod(tmp, mode);
|
||||
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 the linked-cases registry. `fn` receives a copy of the current registry and
|
||||
* returns the registry to write, or `undefined` to write nothing; it may throw to refuse the change
|
||||
* (its error rejects this call as thrown). Resolves with the registry 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 mutateLinkedCases(
|
||||
fn: (current: LinkedCases) => LinkedCases | undefined
|
||||
): Promise<{ before: LinkedCases; after: LinkedCases | undefined }> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const path = linkedCasesPath();
|
||||
const before = await readLinkedCasesForWrite(path).catch((err: unknown) => {
|
||||
console.warn(`[linked-cases] write refused: ${getErrorMessage(err)}`);
|
||||
throw err;
|
||||
});
|
||||
const after = fn({ ...before });
|
||||
if (after) await writeLinkedCasesFile(path, after);
|
||||
return { before, after };
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
@@ -57,7 +57,9 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
||||
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
|
||||
* never be collected), and nothing for a remote (SSH) session, whose
|
||||
* workingDir is the remote path and would name a same-named LOCAL directory
|
||||
* here. The working directory is a user-chosen path, so it is probed BOUNDED
|
||||
* here, or for an adopted session, whose workingDir is the neutral `/tmp` that
|
||||
* other sessions may really work in (it receives no uploads). The working
|
||||
* directory is a user-chosen path, so it is probed BOUNDED
|
||||
* first (#516): one on a mount that stopped answering reads `unknown` and is
|
||||
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
|
||||
* acting on one path at the user's request, passes `pastCap`. The check is
|
||||
@@ -66,10 +68,10 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord';
|
||||
* can.
|
||||
*/
|
||||
export async function uploadDirs(
|
||||
session: { workingDir: string; remote?: unknown },
|
||||
session: { workingDir: string; remote?: unknown; adopt?: unknown },
|
||||
probe: PathProbeOptions = {}
|
||||
): Promise<string[]> {
|
||||
if (session.remote) return [];
|
||||
if (session.remote || session.adopt) return [];
|
||||
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
|
||||
const dataDir = await realDir(getDataDir());
|
||||
const dirs: string[] = [];
|
||||
|
||||
+78
-4
@@ -2458,6 +2458,8 @@ class CodemanApp {
|
||||
}
|
||||
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
|
||||
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
|
||||
// The agent moved into or out of a git worktree while the Files panel is open (#765).
|
||||
if (session.id === this.activeSessionId) this._followFileBrowserWorktree?.(session);
|
||||
// Update parentSessionName for any subagents belonging to this session
|
||||
// (fixes stale name display after session rename)
|
||||
this.updateSubagentParentNames(session.id);
|
||||
@@ -2845,6 +2847,14 @@ class CodemanApp {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
// Code and preformatted text are always left to right. `dir="ltr"`
|
||||
// also isolates them, and `dir="auto"` skips any descendant that
|
||||
// carries a `dir`, so `npm install` at the head of a Persian sentence
|
||||
// no longer turns the whole sentence left to right (issue #715). The
|
||||
// `dir="auto"` itself is `_applyBlockDirection`'s, never this string's.
|
||||
tmpl.content.querySelectorAll('pre, code').forEach((el) => {
|
||||
if (!el.hasAttribute('dir')) el.setAttribute('dir', 'ltr');
|
||||
});
|
||||
return tmpl.innerHTML;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
@@ -3002,10 +3012,38 @@ class CodemanApp {
|
||||
renderedText.className = 'rv-text';
|
||||
renderedText.innerHTML = this._renderMarkdown(text);
|
||||
this._linkifyFilePaths(renderedText);
|
||||
this._applyBlockDirection(renderedText);
|
||||
div.appendChild(renderedText);
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-to-left text (issue #715). With no `dir`, every block was laid out
|
||||
* left to right, so a Persian, Arabic or Hebrew answer was left-aligned, its
|
||||
* punctuation landed on the wrong end and its lists and quotes hung off the
|
||||
* left edge. Each OUTERMOST prose block now takes its direction from its own
|
||||
* first letter (`dir="auto"`, what GitHub does for rendered markdown), so an
|
||||
* RTL paragraph beside an English one reads correctly in both; nested blocks
|
||||
* inherit, which keeps a list's markers and indent on one side. A `dir` the
|
||||
* agent wrote (the sanitizer allows it) is kept, and so is what it covers.
|
||||
*
|
||||
* ⚠️ Call this LAST, on the DOM, once nothing else inserts under `root`: an
|
||||
* element that already carries `dir="auto"` re-resolves its direction on every
|
||||
* insertion beneath it, and a block whose letters all sit in `dir` children
|
||||
* (a code-only or path-only list) walks its whole subtree each time. Set
|
||||
* during `_renderMarkdown` or before the linkifier, that made rendering
|
||||
* quadratic, for English documents too: a 200 KB list of inline code froze
|
||||
* the tab for 40 s. Not part of `_linkifyFilePaths`, which returns early when
|
||||
* a stale constants.js has no `findFilePathLinks`.
|
||||
*/
|
||||
_applyBlockDirection(root) {
|
||||
if (!root) return;
|
||||
for (const el of root.querySelectorAll('p, h1, h2, h3, h4, h5, h6, ul, ol, dl, blockquote, .rv-table-wrap')) {
|
||||
if (el.parentElement?.closest('[dir]') || el.hasAttribute('dir')) continue;
|
||||
el.setAttribute('dir', 'auto');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make file paths in a rendered message clickable: absolute, `~/` and
|
||||
* relative ones (`findFilePathLinks`, constants.js).
|
||||
@@ -3106,6 +3144,9 @@ class CodemanApp {
|
||||
link.dataset.path = path;
|
||||
link.dataset.linkified = '1';
|
||||
if (folder) link.dataset.folder = '1';
|
||||
// A path reads left to right, and in a right-to-left sentence it must
|
||||
// not vote on the sentence's direction (`_applyBlockDirection`, issue #715).
|
||||
link.dir = 'ltr';
|
||||
link.title = path;
|
||||
link.textContent = path;
|
||||
frag.appendChild(link);
|
||||
@@ -10267,11 +10308,11 @@ class CodemanApp {
|
||||
// response re-reports the count, so a pane that starts keeping history
|
||||
// (claude switched to its inline view) goes back to the tail on the next
|
||||
// switch.
|
||||
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
|
||||
// alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
// A remote (ssh), Docker or adopted session's local pane hosts a nested tmux client in
|
||||
// the alternate screen, so it reports 0 while its byte tail is real scrollback.
|
||||
const shellSession = session?.mode === 'shell';
|
||||
const paneKeepsNoHistory =
|
||||
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
!session?.remote && !session?.docker && !session?.adopt && this._paneHistoryLines?.get(sessionId) === 0;
|
||||
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
@@ -10879,6 +10920,9 @@ class CodemanApp {
|
||||
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
|
||||
// the server, which still had the session then.
|
||||
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
|
||||
// The home screen read the outside-sessions list when the tab went, before this
|
||||
// delete landed, so its row would say "Go to tab" with no tab until the next poll.
|
||||
if (session?.adopt) void this.loadForeignSessions?.();
|
||||
if (killMux) {
|
||||
this.showToast('Session closed and tmux killed', 'success');
|
||||
} else {
|
||||
@@ -10914,6 +10958,25 @@ class CodemanApp {
|
||||
const sessionNameEl = document.getElementById('closeConfirmSessionName');
|
||||
sessionNameEl.textContent = name;
|
||||
|
||||
// ⚠️ An ADOPTED session has no "kill" outcome to offer. Its wrapper is the
|
||||
// only thing Codeman owns; the server's killSession refuses to reach the
|
||||
// foreign server at all (see the adopted early return in tmux-manager). So
|
||||
// the red "Terminate the session completely" option is not merely redundant
|
||||
// here, it is a false promise about someone else's live work, and the kind
|
||||
// of false promise that stops a user closing the tab at all. Hide it, and say
|
||||
// what actually happens instead.
|
||||
const killBtn = document.getElementById('closeConfirmKillBtn');
|
||||
const keepTitle = document.getElementById('closeConfirmKeepTitle');
|
||||
const keepDesc = document.getElementById('closeConfirmKeepDesc');
|
||||
const adopted = !!session.adopt;
|
||||
if (killBtn) killBtn.style.display = adopted ? 'none' : '';
|
||||
if (keepTitle) keepTitle.textContent = adopted ? 'Close Tab' : 'Remove Tab';
|
||||
if (keepDesc) {
|
||||
keepDesc.textContent = adopted
|
||||
? 'Detaches only. The tmux session keeps running for whoever started it.'
|
||||
: 'Tmux session keeps running in background';
|
||||
}
|
||||
|
||||
// A session in a Codeman-managed git worktree also offers to remove the worktree
|
||||
// (git worktree remove, never forced) once the session is gone. Read now, before the
|
||||
// session leaves this.sessions.
|
||||
@@ -10973,7 +11036,18 @@ class CodemanApp {
|
||||
this.cancelCloseSession();
|
||||
|
||||
if (sessionId) {
|
||||
const gone = await this.closeSession(sessionId, killMux);
|
||||
// ⚠️ For an ADOPTED session `killMux` does not mean what it means everywhere
|
||||
// else. There is no agent of ours to terminate: the only thing it can reach
|
||||
// is the WRAPPER we created, and the server's adopted branch in
|
||||
// `killSession` tears that down without ever touching the foreign server.
|
||||
// So "keep the tmux session running" has no useful meaning here: taking it
|
||||
// literally leaves our wrapper AND its grouped view session parked on the
|
||||
// user's own socket forever, one pair per tab they ever closed (measured:
|
||||
// `codeman-view-*` survived every close and the row stayed "Go to tab" with
|
||||
// no tab behind it). Always tear the wrapper down; the foreign session is
|
||||
// structurally out of reach either way.
|
||||
const adopted = !!this.sessions.get(sessionId)?.adopt;
|
||||
const gone = await this.closeSession(sessionId, adopted ? true : killMux);
|
||||
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
* `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.
|
||||
* (the end of a turn). No poll, and nothing at all while the panel is closed. A remote session has
|
||||
* no transcript watcher (no hooks reach back from its host), so it follows turn ends only.
|
||||
*
|
||||
* - 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
|
||||
* on any path; inside a local session's workspace it also shows a thumbnail (a remote one would
|
||||
* stream every listed image over ssh). 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
|
||||
@@ -318,10 +320,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
* (`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).
|
||||
* them as a download), nor does a remote session's image: its `file-raw` streams over ssh, and
|
||||
* a list of generated images would open one ssh read per row; a click still previews it.
|
||||
*/
|
||||
_editHistoryThumbSrc(path, sid) {
|
||||
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
|
||||
if (!path || /\.svg$/i.test(path) || this.sessions?.get?.(sid)?.remote) return null;
|
||||
if (this._isExternalPreviewPath?.(path, sid) !== false) return null;
|
||||
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
|
||||
@@ -353,21 +357,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
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 || '' })
|
||||
'edit-history-empty',
|
||||
data.reason === 'adopted'
|
||||
? t('Edit history is not available for a tmux session opened outside Codeman.')
|
||||
: t('Edit history is only available for Claude sessions outside Docker.')
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data.state === 'unavailable') {
|
||||
// A remote host sends no hooks: its transcript is only ever found by the lookup over ssh.
|
||||
const note =
|
||||
data.reason !== 'no-transcript'
|
||||
? t('Showing saved entries only: {reason}', { reason: data.reason || '' })
|
||||
: this.sessions?.get?.(h.sessionId)?.remote
|
||||
? t(
|
||||
'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.'
|
||||
)
|
||||
: 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.'
|
||||
);
|
||||
body.append(el('div', 'edit-history-note edit-history-note--warn', note));
|
||||
}
|
||||
|
||||
const all = Array.isArray(data.entries) ? data.entries : [];
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* @fileoverview The "sessions someone opened by hand" block on the home screen.
|
||||
*
|
||||
* A tmux session a human started (`tmux new -s work`, then `claude`, or just a
|
||||
* shell) is invisible to Codeman: it lives on the DEFAULT socket, not the
|
||||
* instance-scoped one Codeman owns. `GET /api/mux/foreign` finds those, and one
|
||||
* click wraps one in a Codeman tab (`POST /api/sessions/adopt`).
|
||||
*
|
||||
* ## One renderer, two containers
|
||||
*
|
||||
* The welcome screen and the phone overview both show this list. They call the
|
||||
* SAME `renderForeignSessions(container)` rather than each building rows, because
|
||||
* two renderers are how one surface ends up calling a session "shell" while the
|
||||
* other calls it "bash". Same reason `CodemanSessionOrder` is shared.
|
||||
*
|
||||
* ## Polling only while the home screen is up
|
||||
*
|
||||
* The list is a poll, not an SSE stream: the server caches a local scan on a TTL,
|
||||
* so N tabs polling costs one scan per TTL no matter how many are open. But an
|
||||
* open tab that has LEFT the home screen must stop, otherwise every background
|
||||
* tab keeps sweeping tmux sockets forever. `startForeignPolling` /
|
||||
* `stopForeignPolling` are called from `showWelcome`/`hideWelcome`.
|
||||
*
|
||||
* A tab hidden while it shows the home screen skips its ticks as well. The TTL sits
|
||||
* under the poll interval, so one tab never hits the cache: every tick is a full
|
||||
* sweep, one fork or more per socket, for a list nobody is reading. It catches up
|
||||
* on `visibilitychange`, and the cache is what bounds a flurry of tab switches.
|
||||
*
|
||||
* ⚠️ Docker and remote locations are NOT in that poll. Each costs a `docker exec`
|
||||
* or a full ssh handshake per target, each with a ~12s timeout, so riding them
|
||||
* along on an 8s tick stacks connections on a slow host, the one cost the
|
||||
* server-side design explicitly refuses. The wide scan is therefore ONE-SHOT:
|
||||
* turning the toggle on fetches once, and after that it is the user's Rescan
|
||||
* button. The periodic poll stays local-only forever.
|
||||
*
|
||||
* The wide rows are kept separately (`_foreignWideSessions`) and merged into
|
||||
* every local render, because a local-only poll's payload does not contain them
|
||||
* and would otherwise wipe the container/remote rows off the list one tick after
|
||||
* they appeared. Turning the toggle off discards them.
|
||||
*
|
||||
* ⚠️ Adoption creates a REAL session, so the button locks while in flight and the
|
||||
* result goes through the app's normal idempotent create path
|
||||
* (`_onSessionCreated` then `selectSession`), never by inserting a tab here.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, _onSessionCreated, selectSession)
|
||||
* @dependency api-client.js (_api)
|
||||
* @dependency ralph-panel.js (formatRelativeTime)
|
||||
* @dependency i18n.js (codemanT, optional)
|
||||
* @loadorder 12.565 of 16, after home-sessions.js, before git-status-ui.js
|
||||
*/
|
||||
|
||||
/** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
|
||||
const FOREIGN_POLL_FALLBACK_MS = 8000;
|
||||
|
||||
/** i18n.js's translator, or the English source where it has not loaded. */
|
||||
function foreignT(source) {
|
||||
return typeof codemanT === 'function' ? codemanT(source) : source;
|
||||
}
|
||||
|
||||
/** A row's mode, in the registry's own word for it. Falls back to the id, never a list of ids here. */
|
||||
function foreignModeLabel(mode) {
|
||||
const catalog =
|
||||
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
return catalog.find((entry) => entry.id === mode)?.label || mode;
|
||||
}
|
||||
|
||||
const FOREIGN_LOCATION_LABEL = {
|
||||
local: 'this machine',
|
||||
docker: 'container',
|
||||
remote: 'remote',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Begin polling. Idempotent: the home screen re-shows on several paths
|
||||
* (boot, closing the last tab, a breakpoint change) and each would otherwise
|
||||
* stack another interval on top of the last.
|
||||
*/
|
||||
startForeignPolling() {
|
||||
if (this._foreignPollTimer || this._foreignRefused) return;
|
||||
this._foreignPollTick();
|
||||
this._foreignPollTimer = setInterval(
|
||||
() => this._foreignPollTick(),
|
||||
this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS
|
||||
);
|
||||
// Stored, unlike a poller made once: this runs on every trip to the home screen.
|
||||
this._foreignOnVisible = () => this._foreignPollTick();
|
||||
document.addEventListener('visibilitychange', this._foreignOnVisible);
|
||||
},
|
||||
|
||||
/** Stop polling. MUST run on leaving the home screen, see @fileoverview. */
|
||||
stopForeignPolling() {
|
||||
if (!this._foreignPollTimer) return;
|
||||
clearInterval(this._foreignPollTimer);
|
||||
this._foreignPollTimer = null;
|
||||
document.removeEventListener('visibilitychange', this._foreignOnVisible);
|
||||
this._foreignOnVisible = null;
|
||||
},
|
||||
|
||||
/** One poll, and the one gate every poll passes: a hidden tab skips it, see @fileoverview. */
|
||||
_foreignPollTick() {
|
||||
if (document.hidden) return;
|
||||
// Local only: see @fileoverview. The wide scan never rides the timer.
|
||||
void this.loadForeignSessions();
|
||||
},
|
||||
|
||||
/** Whether the user asked for the expensive docker/remote scan too. */
|
||||
foreignScanRemote() {
|
||||
return this._foreignScanRemote === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the wide scan. ON fetches containers and hosts ONCE; OFF discards what
|
||||
* that fetch found, because those rows are not re-confirmed by any later poll
|
||||
* and leaving them up would show a container session that may be long gone.
|
||||
*/
|
||||
toggleForeignScanRemote() {
|
||||
this._foreignScanRemote = !this._foreignScanRemote;
|
||||
if (!this._foreignScanRemote) {
|
||||
this._foreignWideSessions = [];
|
||||
this._foreignWideNotes = [];
|
||||
this.renderAllForeignSessions();
|
||||
return;
|
||||
}
|
||||
void this.loadForeignSessions({ wide: true });
|
||||
},
|
||||
|
||||
/** Re-run the expensive scan on demand. The only way wide rows ever refresh. */
|
||||
rescanForeignWide() {
|
||||
if (!this.foreignScanRemote()) return;
|
||||
void this.loadForeignSessions({ wide: true });
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Data
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Fetch the list. `wide` asks the server for containers and ssh hosts too, and
|
||||
* ONLY an explicit user action ever passes it, never the interval.
|
||||
*/
|
||||
async loadForeignSessions({ wide = false } = {}) {
|
||||
if (this._foreignRefused) return;
|
||||
const qs = wide ? '?docker=1&remote=1' : '';
|
||||
if (wide) this._foreignWideLoading = true;
|
||||
this.renderAllForeignSessions();
|
||||
const res = await this._api(`/api/mux/foreign${qs}`);
|
||||
if (wide) this._foreignWideLoading = false;
|
||||
// ⚠️ A 403 is an answer, not a failed poll. The list is admin-only in multi-user
|
||||
// mode (it shows other people's processes), so a non-admin has nothing to see here
|
||||
// and nothing later on this page changes that: stop asking and show no block,
|
||||
// instead of "Could not reach the server" under a heading they cannot use.
|
||||
if (res?.status === 403) {
|
||||
this._foreignRefused = true;
|
||||
this.stopForeignPolling();
|
||||
this.renderAllForeignSessions();
|
||||
return;
|
||||
}
|
||||
const body = res?.ok ? await this._readJsonBody(res) : null;
|
||||
const data = body?.success === true ? body.data : body?.success === false ? null : body;
|
||||
if (!data) {
|
||||
// A failed poll must not blank a list the user is looking at: keep the
|
||||
// last good result and let the next tick recover.
|
||||
this._foreignError = true;
|
||||
this.renderAllForeignSessions();
|
||||
return;
|
||||
}
|
||||
this._foreignError = false;
|
||||
const rows = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
const notes = Array.isArray(data.notes) ? data.notes : [];
|
||||
// Every note opens with its location (foreign-tmux-discovery.ts). A local one is
|
||||
// re-read on each poll; a container or host one only by the next explicit scan,
|
||||
// so it has to outlive the local polls in between.
|
||||
const isLocalNote = (note) => String(note).startsWith('local:');
|
||||
this._foreignNotes = notes.filter(isLocalNote);
|
||||
if (wide) {
|
||||
// Split by location: the local rows refresh on every tick, the rest only
|
||||
// here, so they have to be stored apart or the next local-only payload
|
||||
// (which cannot contain them) would read as "those sessions are gone".
|
||||
this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local');
|
||||
this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local');
|
||||
this._foreignWideNotes = notes.filter((note) => !isLocalNote(note));
|
||||
this._foreignWideAt = Date.now();
|
||||
} else {
|
||||
this._foreignSessions = rows;
|
||||
}
|
||||
this._foreignCanScanWide = data.canScanWide === true;
|
||||
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
|
||||
this.renderAllForeignSessions();
|
||||
},
|
||||
|
||||
/** Local rows plus whatever the last wide scan found. The render's one input. */
|
||||
_allForeignRows() {
|
||||
const local = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
|
||||
if (!this.foreignScanRemote()) return local;
|
||||
const wide = Array.isArray(this._foreignWideSessions) ? this._foreignWideSessions : [];
|
||||
return wide.length ? local.concat(wide) : local;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adopt one candidate.
|
||||
*
|
||||
* The in-flight lock is global, one adoption at a time from this page: it only
|
||||
* stops a double-click. The server holds the real one-wrapper-per-target
|
||||
* guarantee.
|
||||
*/
|
||||
async adoptForeignSession(id, buttonEl) {
|
||||
if (this._foreignAdoptInFlight) return;
|
||||
this._foreignAdoptInFlight = true;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = foreignT('Opening…');
|
||||
}
|
||||
try {
|
||||
const body = { id };
|
||||
if (this.foreignScanRemote()) {
|
||||
body.docker = true;
|
||||
body.remote = true;
|
||||
}
|
||||
// ⚠️ `_api`, not `_apiJson`: the latter folds a network failure, a non-2xx
|
||||
// and `success:false` all into null, and the server deliberately tells
|
||||
// two different stories here: "that tmux session is no longer there" vs
|
||||
// "could not reach it just now (…), try again". Collapsing them showed a
|
||||
// live remote session as deleted every time the link flickered.
|
||||
const res = await this._api('/api/sessions/adopt', { method: 'POST', body });
|
||||
const payload = await this._readJsonBody(res);
|
||||
const session = payload && payload.success !== false ? payload.data?.session : null;
|
||||
if (!session) {
|
||||
const fallback = foreignT('Could not open that session. Refreshing the list.');
|
||||
this.showToast?.(payload?.error || fallback, 'error');
|
||||
await this.loadForeignSessions();
|
||||
return;
|
||||
}
|
||||
const data = { session };
|
||||
// Go through the app's normal create path so tab order, lineage lines and
|
||||
// SSE-vs-POST ordering behave exactly as they do for a Run.
|
||||
this._onSessionCreated?.(data.session);
|
||||
await this.selectSession(data.session.id);
|
||||
// Still one explicit user action, so a wide row's "Open" refreshes the
|
||||
// wide list too, otherwise the row just adopted keeps reading as free.
|
||||
await this.loadForeignSessions({ wide: this.foreignScanRemote() });
|
||||
} finally {
|
||||
this._foreignAdoptInFlight = false;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = foreignT('Open');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** The error envelope, or null when there was no readable body at all. */
|
||||
async _readJsonBody(res) {
|
||||
if (!res) return null;
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Render
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Paint every mounted container (welcome + phone overview). */
|
||||
renderAllForeignSessions() {
|
||||
for (const id of ['foreignSessions', 'mobileForeignSessions']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) this.renderForeignSessions(el);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render into one container. The ONLY row builder, see @fileoverview.
|
||||
*
|
||||
* Everything is built with DOM APIs and `textContent`: a session name and a
|
||||
* command line come from another user's process, so they are never allowed
|
||||
* near `innerHTML`.
|
||||
*/
|
||||
renderForeignSessions(container) {
|
||||
const rows = this._allForeignRows();
|
||||
container.innerHTML = '';
|
||||
if (this._foreignRefused) {
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
// Hidden rather than shown-empty: on a first run there is usually nothing,
|
||||
// and an empty block on the welcome screen reads as a broken feature.
|
||||
// A note is a REASON the list is empty, so a block carrying one must stay
|
||||
// visible: hiding it is exactly how "where did my session go" becomes
|
||||
// unanswerable.
|
||||
const hasNotes =
|
||||
(Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0) ||
|
||||
(Array.isArray(this._foreignWideNotes) && this._foreignWideNotes.length > 0);
|
||||
// ⚠️ `canScanWide` must keep the block visible even with nothing to show.
|
||||
// The scan toggle lives in the header, so hiding an empty block also hides
|
||||
// the only control that could fill it: on a host with containers but no
|
||||
// local tmux sessions that made the whole feature unreachable.
|
||||
container.hidden = !this.foreignScanRemote() && !this._foreignError && !hasNotes && !this._foreignCanScanWide;
|
||||
if (!container.hidden) {
|
||||
container.appendChild(this._foreignHeader(0));
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'foreign-empty';
|
||||
this._appendForeignNotes(container);
|
||||
empty.textContent = this._foreignError
|
||||
? foreignT('Could not reach the server.')
|
||||
: foreignT('No sessions found outside Codeman.');
|
||||
container.appendChild(empty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
container.hidden = false;
|
||||
container.appendChild(this._foreignHeader(rows.length));
|
||||
|
||||
this._appendForeignNotes(container);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'foreign-list';
|
||||
// Adoptable first: an already-open one is not an action, it is a reminder.
|
||||
const sorted = rows.slice().sort((a, b) => {
|
||||
const aOpen = a.adoptedBy ? 1 : 0;
|
||||
const bOpen = b.adoptedBy ? 1 : 0;
|
||||
if (aOpen !== bOpen) return aOpen - bOpen;
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
});
|
||||
for (const row of sorted) list.appendChild(this._foreignRow(row));
|
||||
container.appendChild(list);
|
||||
},
|
||||
|
||||
/**
|
||||
* Why a scan produced less than the user expected.
|
||||
*
|
||||
* Without this, a session skipped for an unsafe name or a host that could not
|
||||
* be reached is simply ABSENT, and "my tmux session does not show up" has no
|
||||
* answer anywhere in the product. Notes are server-authored strings, rendered
|
||||
* as text.
|
||||
*/
|
||||
_appendForeignNotes(container) {
|
||||
const local = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
|
||||
const wide = this.foreignScanRemote() && Array.isArray(this._foreignWideNotes) ? this._foreignWideNotes : [];
|
||||
const notes = wide.length ? local.concat(wide) : local;
|
||||
if (!notes.length) return;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'foreign-notes';
|
||||
box.setAttribute('data-i18n-skip', '');
|
||||
for (const n of notes) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'foreign-note';
|
||||
line.textContent = n;
|
||||
box.appendChild(line);
|
||||
}
|
||||
container.appendChild(box);
|
||||
},
|
||||
|
||||
_foreignHeader(count) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'foreign-header';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'foreign-title';
|
||||
title.textContent = foreignT('Opened outside Codeman');
|
||||
header.appendChild(title);
|
||||
|
||||
if (count) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'foreign-count';
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
badge.textContent = String(count);
|
||||
header.appendChild(badge);
|
||||
}
|
||||
|
||||
const scan = document.createElement('button');
|
||||
scan.type = 'button';
|
||||
scan.className = 'foreign-scan-toggle';
|
||||
scan.dataset.foreignAction = 'toggle-scan';
|
||||
scan.setAttribute('aria-pressed', String(this.foreignScanRemote()));
|
||||
scan.title = foreignT('Also scan containers and remote hosts (slower)');
|
||||
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
|
||||
header.appendChild(scan);
|
||||
|
||||
// The wide scan is one-shot, so without this button its rows could only
|
||||
// ever be refreshed by toggling off and on again.
|
||||
if (this.foreignScanRemote()) {
|
||||
const rescan = document.createElement('button');
|
||||
rescan.type = 'button';
|
||||
rescan.className = 'foreign-scan-toggle';
|
||||
rescan.dataset.foreignAction = 'rescan-wide';
|
||||
rescan.disabled = this._foreignWideLoading === true;
|
||||
rescan.title = foreignT('Scan containers and remote hosts again');
|
||||
rescan.textContent = this._foreignWideLoading ? foreignT('Scanning…') : foreignT('Rescan');
|
||||
header.appendChild(rescan);
|
||||
}
|
||||
|
||||
return header;
|
||||
},
|
||||
|
||||
_foreignRow(row) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'foreign-row';
|
||||
if (row.adoptedBy) item.classList.add('foreign-row--open');
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `foreign-dot foreign-dot--${row.mode || 'shell'}`;
|
||||
item.appendChild(dot);
|
||||
|
||||
const body = document.createElement('span');
|
||||
body.className = 'foreign-row-body';
|
||||
|
||||
const line1 = document.createElement('span');
|
||||
line1.className = 'foreign-row-name';
|
||||
line1.setAttribute('data-i18n-skip', '');
|
||||
line1.textContent = row.sessionName || '(unnamed)';
|
||||
body.appendChild(line1);
|
||||
|
||||
const line2 = document.createElement('span');
|
||||
line2.className = 'foreign-row-sub';
|
||||
line2.setAttribute('data-i18n-skip', '');
|
||||
const bits = [foreignModeLabel(row.mode)];
|
||||
const where = row.hostLabel || foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location);
|
||||
if (where) bits.push(where);
|
||||
if (row.workingDir) bits.push(row.workingDir);
|
||||
line2.textContent = bits.join(' · ');
|
||||
line2.title = row.command || '';
|
||||
body.appendChild(line2);
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const action = document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.className = 'foreign-open-btn';
|
||||
if (row.adoptedBy) {
|
||||
action.dataset.foreignAction = 'select';
|
||||
action.dataset.foreignSession = row.adoptedBy;
|
||||
action.textContent = foreignT('Go to tab');
|
||||
} else {
|
||||
action.dataset.foreignAction = 'adopt';
|
||||
action.dataset.foreignId = row.id;
|
||||
action.textContent = foreignT('Open');
|
||||
}
|
||||
item.appendChild(action);
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated listener per container, wired once. Delegation matters here
|
||||
* because the list is fully rebuilt on every poll: per-button listeners would
|
||||
* leak one set per tick.
|
||||
*/
|
||||
wireForeignSessions() {
|
||||
if (this._foreignWired) return;
|
||||
this._foreignWired = true;
|
||||
document.addEventListener('click', (event) => {
|
||||
const el = event.target?.closest?.('[data-foreign-action]');
|
||||
if (!el) return;
|
||||
const action = el.dataset.foreignAction;
|
||||
if (action === 'adopt') {
|
||||
void this.adoptForeignSession(el.dataset.foreignId, el);
|
||||
} else if (action === 'select') {
|
||||
void this.selectSession(el.dataset.foreignSession);
|
||||
} else if (action === 'toggle-scan') {
|
||||
this.toggleForeignScanRemote();
|
||||
} else if (action === 'rescan-wide') {
|
||||
this.rescanForeignWide();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -425,9 +425,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
overview.state === 'not-a-repo'
|
||||
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
|
||||
: overview.state === 'unsupported'
|
||||
? overview.reason === 'docker'
|
||||
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
|
||||
: 'Git status is not available for remote (SSH) sessions.'
|
||||
? overview.reason === 'adopted'
|
||||
? 'Git status is not available for a tmux session opened outside Codeman.'
|
||||
: overview.reason === 'docker'
|
||||
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
|
||||
: 'Git status is not available for remote (SSH) sessions.'
|
||||
: `Could not read the repository: ${overview.error || 'git failed'}`;
|
||||
body.append(el('div', 'git-status-empty', why));
|
||||
clearChrome();
|
||||
|
||||
+36
-1
@@ -55,6 +55,9 @@
|
||||
'应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。',
|
||||
'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"':
|
||||
'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"',
|
||||
'Allow network access': '允许网络访问',
|
||||
"Lets commands in Codex's workspace-write sandbox use the network, which the Codeman skill needs to reach this server. Those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set. So with this on, Codex's sandbox does not contain a prompt-injected command: treat it as close to Bypass. Applies to every new local Codex session, not scheduled jobs. It does not change Codex's sandbox mode: a folder Codex runs read-only (one it does not trust) keeps the network closed and asks per command.":
|
||||
'允许 Codex 的 workspace-write 沙箱中的命令使用网络,Codeman 技能需要网络才能连接到此服务器。这些命令随后可以调用 Codeman 的 API,而该 API 可以在沙箱之外启动会话且不弹出审批提示,即使设置了密码也是如此。因此开启后,Codex 的沙箱无法约束被提示注入的命令:请把它视为接近“绕过审批与沙箱”。应用于所有新的本地 Codex 会话,不包括定时任务。它不会改变 Codex 的沙箱模式:Codex 以只读方式运行的文件夹(即它不信任的文件夹)仍会保持网络关闭,并逐条命令询问。',
|
||||
'Skip to terminal': '跳转到终端',
|
||||
'Go to main page': '返回主页',
|
||||
'Session tabs': '会话标签页',
|
||||
@@ -565,10 +568,15 @@
|
||||
'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 会话。',
|
||||
'Edit history is only available for Claude sessions outside Docker.':
|
||||
'编辑历史仅适用于 Docker 之外的 Claude 会话。',
|
||||
'Edit history is not available for a tmux session opened outside Codeman.':
|
||||
'在 Codeman 之外打开的 tmux 会话无法使用编辑历史。',
|
||||
'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 transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.':
|
||||
'远程主机上尚未找到记录(Claude 会在第一次提示时写入)。仅在 shell 配置文件(.profile、.bash_profile、.zshrc)中设置的 CLAUDE_CONFIG_DIR 无法被找到。',
|
||||
'No edits yet.': '暂无编辑。',
|
||||
'No edits by the main agent.': '主智能体没有编辑。',
|
||||
'Load older': '加载更早的编辑',
|
||||
@@ -1141,6 +1149,10 @@
|
||||
'Filter by date range': '按日期范围筛选',
|
||||
'Filter by session status': '按会话状态筛选',
|
||||
'Filter files...': '筛选文件…',
|
||||
Worktree: '工作树',
|
||||
'Session folder': '会话文件夹',
|
||||
'Back to worktree': '返回工作树',
|
||||
'Not fully scanned': '未完全扫描',
|
||||
'Any status': '任意状态',
|
||||
'Any time': '任意时间',
|
||||
'Last hour': '最近一小时',
|
||||
@@ -1380,6 +1392,29 @@
|
||||
'Remove Tab': '移除标签页',
|
||||
'Remove All Tabs': '移除所有标签页',
|
||||
'Use arrows to reorder. Changes are saved automatically.': '使用方向键重新排序;更改会自动保存。',
|
||||
// Sessions opened outside Codeman (foreign-sessions.js, the close dialog and Session Options).
|
||||
'Opened outside Codeman': '在 Codeman 之外打开',
|
||||
'Also scan containers and remote hosts (slower)': '同时扫描容器和远程主机(较慢)',
|
||||
'Scan containers and remote hosts again': '重新扫描容器和远程主机',
|
||||
'⟳ all': '⟳ 全部',
|
||||
'⟳ local': '⟳ 本机',
|
||||
'Scanning…': '扫描中…',
|
||||
Rescan: '重新扫描',
|
||||
'Opening…': '正在打开…',
|
||||
'Go to tab': '转到标签页',
|
||||
'this machine': '本机',
|
||||
container: '容器',
|
||||
remote: '远程',
|
||||
'No sessions found outside Codeman.': '未发现在 Codeman 之外打开的会话。',
|
||||
'Could not reach the server.': '无法连接服务器。',
|
||||
'Could not open that session. Refreshing the list.': '无法打开该会话,正在刷新列表。',
|
||||
'Close Tab': '关闭标签页',
|
||||
'Detaches only. The tmux session keeps running for whoever started it.':
|
||||
'仅断开连接。该 tmux 会话会继续运行,供其启动者使用。',
|
||||
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.':
|
||||
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
|
||||
'Git status is not available for a tmux session opened outside Codeman.':
|
||||
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
|
||||
});
|
||||
|
||||
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
|
||||
|
||||
@@ -583,6 +583,12 @@
|
||||
</div>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<!-- Sessions a human started outside Codeman (foreign-sessions.js).
|
||||
Hidden until a scan finds one; never rendered empty on a fresh
|
||||
install, where an empty block reads as a broken feature. -->
|
||||
<div class="welcome-foreign">
|
||||
<div class="foreign-sessions" id="foreignSessions" hidden></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or click Run to start</p>
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
@@ -617,6 +623,11 @@
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-browser-root" id="fileBrowserRoot" hidden>
|
||||
<span class="file-browser-root-label" id="fileBrowserRootLabel"></span>
|
||||
<span class="file-browser-root-name" id="fileBrowserRootName" data-i18n-skip></span>
|
||||
<button type="button" class="file-browser-root-switch" id="fileBrowserRootSwitch" onclick="app.toggleFileBrowserRoot()">Session folder</button>
|
||||
</div>
|
||||
<div class="file-browser-search">
|
||||
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
|
||||
</div>
|
||||
@@ -1829,11 +1840,11 @@
|
||||
<p class="modal-session-name" id="closeConfirmSessionName"></p>
|
||||
</div>
|
||||
<div class="close-options">
|
||||
<button class="close-option" onclick="app.confirmCloseSession(false)">
|
||||
<span class="close-option-title">Remove Tab</span>
|
||||
<span class="close-option-desc">Tmux session keeps running in background</span>
|
||||
<button class="close-option" onclick="app.confirmCloseSession(false)" id="closeConfirmKeepBtn">
|
||||
<span class="close-option-title" id="closeConfirmKeepTitle">Remove Tab</span>
|
||||
<span class="close-option-desc" id="closeConfirmKeepDesc">Tmux session keeps running in background</span>
|
||||
</button>
|
||||
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true)">
|
||||
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true)" id="closeConfirmKillBtn">
|
||||
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
|
||||
<span class="close-option-desc">Terminate the session completely</span>
|
||||
</button>
|
||||
@@ -3045,6 +3056,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexDangerouslyBypassApprovals"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="codex network access sandbox loopback codeman skill orchestrate spawn">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Allow network access</span>
|
||||
<span class="set-row-desc">Lets commands in Codex's workspace-write sandbox use the network, which the Codeman skill needs to reach this server. Those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set. So with this on, Codex's sandbox does not contain a prompt-injected command: treat it as close to Bypass. Applies to every new local Codex session, not scheduled jobs. It does not change Codex's sandbox mode: a folder Codex runs read-only (one it does not trust) keeps the network closed and asks per command.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexNetworkAccess"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="codex animations status effects">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Animated status effects</span>
|
||||
@@ -3791,15 +3809,15 @@
|
||||
</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>
|
||||
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder instead and the repository is cloned into a subfolder of it named after the case, the same way it goes under ~/codeman-cases; 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>
|
||||
<label>Parent Folder</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span>
|
||||
<span class="form-hint" id="cloneCasePathPreview">Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Branch or Tag (optional)</label>
|
||||
@@ -4664,6 +4682,7 @@
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="foreign-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>
|
||||
|
||||
@@ -1371,7 +1371,7 @@ const KeyboardAccessoryBar = {
|
||||
<strong>Compose prompt</strong>
|
||||
<span>Enter adds a new line</span>
|
||||
</div>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" dir="auto" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">📎 Attach</button>
|
||||
@@ -1384,6 +1384,8 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
this._composerOverlay = overlay;
|
||||
const textarea = overlay.querySelector('.prompt-composer-textarea');
|
||||
// The textarea's dir="auto" lays a prompt written in Persian, Arabic or
|
||||
// Hebrew out right to left, from its first letter (#715).
|
||||
// i18n.js skips <textarea> subtrees (what is typed there is user content),
|
||||
// so the placeholder and label are translated here, when the dialog is built.
|
||||
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
|
||||
|
||||
@@ -508,6 +508,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
)
|
||||
);
|
||||
|
||||
// Sessions a human opened outside Codeman. Its own container, rebuilt by the
|
||||
// ONE renderer in foreign-sessions.js: the phone must not grow a second row
|
||||
// builder that could describe the same session differently from the desktop.
|
||||
const foreign = document.createElement('div');
|
||||
foreign.className = 'foreign-sessions mobile-foreign-sessions';
|
||||
foreign.id = 'mobileForeignSessions';
|
||||
foreign.hidden = true;
|
||||
el.appendChild(foreign);
|
||||
this.renderForeignSessions?.(foreign);
|
||||
|
||||
el.appendChild(
|
||||
this._buildMobileOverviewSection(
|
||||
'Past sessions',
|
||||
|
||||
@@ -4217,6 +4217,18 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
||||
}
|
||||
}
|
||||
|
||||
/* Foreign sessions block inside the phone overview (foreign-sessions.js).
|
||||
The desktop block sits inside the welcome column; here it is a full-width
|
||||
section between CURRENT and PAST, so it only needs the surrounding spacing:
|
||||
every row style is shared with styles.css on purpose. */
|
||||
.mobile-foreign-sessions {
|
||||
margin: 0.75rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.mobile-foreign-sessions .foreign-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Folding devices, tabletop pose: cap the response viewer to the bottom
|
||||
segment. Twin of the rule at the end of styles.css, needed here because the
|
||||
phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same
|
||||
|
||||
@@ -3291,6 +3291,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
matches: [],
|
||||
deferredDirectoryTarget: null,
|
||||
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
|
||||
// The person asked for the session folder while the agent is in a worktree (#765).
|
||||
sessionRootPinned: false,
|
||||
};
|
||||
}
|
||||
return this._fileBrowserState;
|
||||
@@ -3313,6 +3315,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3323,6 +3326,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
|
||||
const panel = this.$?.('fileBrowserPanel');
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
@@ -3355,6 +3359,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3365,6 +3370,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
if (treeEl) treeEl.innerHTML = '';
|
||||
const statusEl = this.$?.('fileBrowserStatus');
|
||||
@@ -3457,6 +3463,60 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
this.renderFileBrowserTree(normalState.sessionId);
|
||||
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
||||
this._renderFileBrowserRoot(normalState.data);
|
||||
},
|
||||
|
||||
// The strip under the panel header, present only while the session's pane is in a linked
|
||||
// worktree (#765): it says which of the two folders is listed and switches between them.
|
||||
// Everything here comes from GET .../files, so the strip describes the tree on screen rather
|
||||
// than the session's state a moment later.
|
||||
_renderFileBrowserRoot(data) {
|
||||
const strip = this.$?.('fileBrowserRoot');
|
||||
if (!strip) return;
|
||||
const worktree = data?.worktree;
|
||||
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
|
||||
strip.hidden = true;
|
||||
return;
|
||||
}
|
||||
const inWorktree = data.root === worktree.path;
|
||||
const label = this.$?.('fileBrowserRootLabel');
|
||||
const name = this.$?.('fileBrowserRootName');
|
||||
const button = this.$?.('fileBrowserRootSwitch');
|
||||
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
|
||||
if (name) name.textContent = inWorktree ? worktree.name : '';
|
||||
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
|
||||
strip.title = typeof data.root === 'string' ? data.root : '';
|
||||
strip.hidden = false;
|
||||
},
|
||||
|
||||
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
|
||||
// a worktree, so the panel always asks unless the person chose the session folder.
|
||||
_fileBrowserRootParam() {
|
||||
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
|
||||
},
|
||||
|
||||
toggleFileBrowserRoot() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
state.sessionRootPinned = !state.sessionRootPinned;
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Called on every session update of the active session. Compares with what the listing on
|
||||
// screen reported rather than with the previous update, so a load already in flight when the
|
||||
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
|
||||
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
|
||||
_followFileBrowserWorktree(session) {
|
||||
const state = this._fileBrowserState;
|
||||
if (!state || state.ownerSessionId !== session.id) return;
|
||||
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
|
||||
const normal = state.normalState;
|
||||
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
|
||||
const live = session.liveWorktree?.path ?? null;
|
||||
const listed = normal.data?.worktree?.path ?? null;
|
||||
if (live === listed) return;
|
||||
// The pin was about the worktree the agent has just left.
|
||||
state.sessionRootPinned = false;
|
||||
this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
_validateFileBrowserTreeEnvelope(result) {
|
||||
@@ -3561,14 +3621,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async toggleFileBrowserHidden() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||
try {
|
||||
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
||||
} catch {}
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Re-fetch the tree after something the server filters on changed (hidden files, the listing's
|
||||
// root), then re-run the search in the box against the new tree.
|
||||
async _reloadFileBrowserKeepingQuery() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
@@ -3642,7 +3708,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const request = (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}${this._fileBrowserRootParam()}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load files');
|
||||
const result = await res.json();
|
||||
@@ -3737,6 +3803,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
renderNode(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// The scan stopped at a limit or the folder did not answer, so what is listed above is not all
|
||||
// of it. Not a .file-tree-item: the click wiring below would try to open it as a file.
|
||||
if (isDir && isExpanded && node.incomplete === true) {
|
||||
html.push(`<div class="file-tree-incomplete" data-depth="${depth + 1}">${escapeHtml('Not fully scanned')}</div>`);
|
||||
}
|
||||
};
|
||||
|
||||
for (const node of tree) {
|
||||
@@ -3829,6 +3901,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showHidden: this.fileBrowserShowHidden === true,
|
||||
rawInput,
|
||||
query,
|
||||
rootParam: this._fileBrowserRootParam(),
|
||||
timer: null,
|
||||
};
|
||||
state.view = 'search-pending';
|
||||
@@ -3843,7 +3916,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
request.timer = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}${request.rootParam}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const result = await res.json();
|
||||
@@ -5080,6 +5153,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const doc = tmpl.content.firstElementChild;
|
||||
this._rebaseFilePreviewMarkdownRefs(doc, info);
|
||||
this._linkifyFilePaths(doc);
|
||||
this._applyBlockDirection(doc);
|
||||
// The linkifier's absolute paths name no session; give them the
|
||||
// preview's, like the rebased links, or they open in the active tab's.
|
||||
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
|
||||
|
||||
@@ -2757,8 +2757,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** Copy the open session's working folder (Session Options > Session > Working folder). */
|
||||
async copySessionPath() {
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (!session?.workingDir) return;
|
||||
const ok = await this._copyText(session.workingDir);
|
||||
const path = session?.adopt?.paneCurrentPath || session?.workingDir;
|
||||
if (!path) return;
|
||||
const ok = await this._copyText(path);
|
||||
this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error');
|
||||
},
|
||||
|
||||
@@ -2833,8 +2834,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
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' : '';
|
||||
// The server never watches a remote session's directory (it is on another host), nor an adopted
|
||||
// session's (a neutral wrapper folder), so the row is hidden.
|
||||
imageWatcherToggle.closest('.set-row').style.display = session.remote || session.adopt ? 'none' : '';
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
// Populate session name input with prefix/suffix split
|
||||
@@ -2853,17 +2855,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Working folder: the path the CLI was launched in on the Codeman host
|
||||
// (an adopted session's is the neutral wrapper folder, so the row shows where the pane was)
|
||||
const _pathEl = document.getElementById('modalSessionPath');
|
||||
_pathEl.textContent = session.workingDir || '';
|
||||
_pathEl.title = session.workingDir || '';
|
||||
document.getElementById('modalSessionPathCopy').disabled = !session.workingDir;
|
||||
const _shownPath = session.adopt?.paneCurrentPath || session.workingDir || '';
|
||||
_pathEl.textContent = _shownPath;
|
||||
_pathEl.title = _shownPath;
|
||||
document.getElementById('modalSessionPathCopy').disabled = !_shownPath;
|
||||
// A remote session's workingDir is the REMOTE path; a docker session's is the
|
||||
// host bind-mount source the container sees.
|
||||
document.getElementById('modalSessionPathDesc').textContent = session.remote
|
||||
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
|
||||
: session.docker
|
||||
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
|
||||
: "Where this session's CLI runs, on the machine hosting Codeman.";
|
||||
document.getElementById('modalSessionPathDesc').textContent = session.adopt
|
||||
? 'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.'
|
||||
: session.remote
|
||||
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
|
||||
: session.docker
|
||||
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
|
||||
: "Where this session's CLI runs, on the machine hosting Codeman.";
|
||||
|
||||
// Initialize color picker with current session color
|
||||
const currentColor = session.color || 'default';
|
||||
@@ -2890,11 +2896,11 @@ 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.
|
||||
// Git is local git only: a remote (SSH) or Docker case, or an adopted session, 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' : '';
|
||||
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker || session.adopt ? 'none' : '';
|
||||
|
||||
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
|
||||
if (!isExternalCli) {
|
||||
@@ -4051,6 +4057,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
title: 'Choose the folder to create the case in',
|
||||
initialPath: input.value.trim(),
|
||||
directoriesOnly: true,
|
||||
allowNewFolder: true,
|
||||
onSelect: (path) => {
|
||||
input.value = path;
|
||||
this.updateNewCasePathPreview();
|
||||
@@ -4264,32 +4271,37 @@ Object.assign(CodemanApp.prototype, {
|
||||
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. Names the case and the subfolder of the chosen folder the repository is cloned into.'
|
||||
: '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). */
|
||||
/** The folder the repository is cloned into: the parent field plus the case name, as Create New does. */
|
||||
_cloneTargetPath() {
|
||||
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
|
||||
const rawParent = (document.getElementById('cloneCasePath')?.value || '').trim();
|
||||
const name = (document.getElementById('cloneCaseName')?.value || '').trim();
|
||||
if (!rawParent || !name) return '';
|
||||
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
|
||||
return `${rawParent.replace(/\/+$/, '')}/${name}`;
|
||||
},
|
||||
|
||||
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.';
|
||||
hint.textContent = target
|
||||
? `Will clone into: ${target}`
|
||||
: 'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.';
|
||||
},
|
||||
|
||||
openCloneCasePathPicker() {
|
||||
const input = document.getElementById('cloneCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Choose the folder to clone into',
|
||||
title: 'Choose the folder to clone inside',
|
||||
initialPath: input.value.trim(),
|
||||
directoriesOnly: true,
|
||||
allowNewFolder: true,
|
||||
onSelect: (path) => {
|
||||
input.value = path;
|
||||
this.updateCloneCasePathPreview();
|
||||
@@ -4334,12 +4346,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
onCloneNameEdited() {
|
||||
// Once the user types a name, autofill stops fighting them.
|
||||
this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim();
|
||||
this.updateCloneCasePathPreview();
|
||||
},
|
||||
|
||||
onCloneUrlInput() {
|
||||
const url = document.getElementById('cloneRepoUrl')?.value.trim() || '';
|
||||
const nameInput = document.getElementById('cloneCaseName');
|
||||
if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url);
|
||||
this.updateCloneCasePathPreview();
|
||||
clearTimeout(this._clonePreflightTimer);
|
||||
this._clonePreflightAbort?.abort();
|
||||
this._clonePreflightAbort = null;
|
||||
@@ -4395,6 +4409,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the case name is validated against), but never over a name the user typed.
|
||||
const nameInput = document.getElementById('cloneCaseName');
|
||||
if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName;
|
||||
this.updateCloneCasePathPreview();
|
||||
|
||||
const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`;
|
||||
if (data.gitAvailable === false) {
|
||||
@@ -4449,7 +4464,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked;
|
||||
if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) {
|
||||
this.showToast('Choose the folder to clone into', 'error');
|
||||
this.showToast('Choose the folder to clone inside', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6111,7 +6126,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
|
||||
_managedWorktreeOfSession(session) {
|
||||
if (!session || session.remote || session.docker) return null;
|
||||
if (!session || session.remote || session.docker || session.adopt) return null;
|
||||
return managedWorktreeOfDir(session.workingDir, this.cases);
|
||||
},
|
||||
|
||||
|
||||
@@ -699,6 +699,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// that does have it.
|
||||
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
|
||||
settings.codexDangerouslyBypassApprovals ?? false;
|
||||
document.getElementById('appSettingsCodexNetworkAccess').checked = settings.codexNetworkAccess ?? false;
|
||||
document.getElementById('appSettingsCodexAnimations').checked =
|
||||
settings.codexAnimationsEnabled ?? false;
|
||||
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
|
||||
@@ -3209,6 +3210,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
|
||||
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
|
||||
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
|
||||
codexNetworkAccess: document.getElementById('appSettingsCodexNetworkAccess').checked,
|
||||
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
|
||||
+240
-15
@@ -12207,6 +12207,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-browser-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-root[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser-root-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-browser-root-switch {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-browser-root-switch:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-search {
|
||||
padding: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -12299,6 +12338,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Under an expanded folder the scan's file limit stopped short of. Inert: no hover, no click. */
|
||||
.file-tree-incomplete {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.file-tree-icon {
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
@@ -12355,15 +12404,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
|
||||
/* File tree indent levels */
|
||||
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
|
||||
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
|
||||
.file-tree-item[data-depth="2"] { padding-left: 2rem; }
|
||||
.file-tree-item[data-depth="3"] { padding-left: 2.75rem; }
|
||||
.file-tree-item[data-depth="4"] { padding-left: 3.5rem; }
|
||||
.file-tree-item[data-depth="5"] { padding-left: 4.25rem; }
|
||||
.file-tree-item[data-depth="6"] { padding-left: 5rem; }
|
||||
.file-tree-item[data-depth="7"] { padding-left: 5.75rem; }
|
||||
.file-tree-item[data-depth="8"] { padding-left: 6.5rem; }
|
||||
.file-tree-item[data-depth="9"] { padding-left: 7.25rem; }
|
||||
.file-tree-item[data-depth="1"], .file-tree-incomplete[data-depth="1"] { padding-left: 1.25rem; }
|
||||
.file-tree-item[data-depth="2"], .file-tree-incomplete[data-depth="2"] { padding-left: 2rem; }
|
||||
.file-tree-item[data-depth="3"], .file-tree-incomplete[data-depth="3"] { padding-left: 2.75rem; }
|
||||
.file-tree-item[data-depth="4"], .file-tree-incomplete[data-depth="4"] { padding-left: 3.5rem; }
|
||||
.file-tree-item[data-depth="5"], .file-tree-incomplete[data-depth="5"] { padding-left: 4.25rem; }
|
||||
.file-tree-item[data-depth="6"], .file-tree-incomplete[data-depth="6"] { padding-left: 5rem; }
|
||||
.file-tree-item[data-depth="7"], .file-tree-incomplete[data-depth="7"] { padding-left: 5.75rem; }
|
||||
.file-tree-item[data-depth="8"], .file-tree-incomplete[data-depth="8"] { padding-left: 6.5rem; }
|
||||
.file-tree-item[data-depth="9"], .file-tree-incomplete[data-depth="9"] { padding-left: 7.25rem; }
|
||||
|
||||
/* ========== File Preview Overlay ========== */
|
||||
|
||||
@@ -15212,7 +15261,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.rv-text ul, .rv-text ol,
|
||||
.response-viewer-body > ul, .response-viewer-body > ol {
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1.5em;
|
||||
padding-inline-start: 1.5em;
|
||||
}
|
||||
|
||||
.rv-text li,
|
||||
@@ -15224,12 +15273,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
|
||||
.rv-text blockquote,
|
||||
.response-viewer-body > blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
border-inline-start: 3px solid var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.06);
|
||||
margin: 0.8em 0;
|
||||
padding: 0.5em 14px;
|
||||
color: var(--text-dim);
|
||||
border-radius: 0 6px 6px 0;
|
||||
/* Logical corners: the bar sits on the start side, right-to-left too (#715). */
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-start-end-radius: 6px;
|
||||
border-end-end-radius: 6px;
|
||||
}
|
||||
|
||||
.rv-text strong,
|
||||
@@ -15299,9 +15352,9 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.response-viewer-body > table td,
|
||||
.rv-table-wrap th, .rv-table-wrap td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
border-inline-end: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -15310,7 +15363,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.response-viewer-body > table th:last-child,
|
||||
.response-viewer-body > table td:last-child,
|
||||
.rv-table-wrap th:last-child, .rv-table-wrap td:last-child {
|
||||
border-right: none;
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
||||
.rv-text tr:last-child td,
|
||||
@@ -15977,8 +16030,10 @@ body.touch-device.cjk-input-visible .main {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* An ordinary status fits one line; a refusal may not, and it must be readable whole. */
|
||||
.path-picker-status.error {
|
||||
color: var(--danger, #ef4444);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.path-picker-list {
|
||||
@@ -22078,6 +22133,176 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Foreign sessions: tmux sessions a human started outside Codeman
|
||||
(foreign-sessions.js). Rendered on the welcome screen and, with the
|
||||
same row builder, inside the phone overview.
|
||||
|
||||
Colour vocabulary is deliberately the session-tab one: a mode dot on
|
||||
the left, name over a dim meta line, action pinned right. A block that
|
||||
invented its own language here would read as a different product.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.welcome-foreign {
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.foreign-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* `.foreign-sessions` is a flex container, so `[hidden]` needs re-asserting or
|
||||
the module's only visibility lever does nothing (same trap as .home-sessions). */
|
||||
.foreign-sessions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.foreign-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.foreign-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.foreign-count {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foreign-scan-toggle {
|
||||
margin-left: auto;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.foreign-scan-toggle[aria-pressed='true'] {
|
||||
color: var(--session-blue, #4a9eff);
|
||||
border-color: var(--session-blue, #4a9eff);
|
||||
}
|
||||
|
||||
.foreign-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
max-height: min(40vh, 320px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.foreign-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.foreign-row--open {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.foreign-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
background: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.foreign-dot--claude {
|
||||
background: #d97757;
|
||||
}
|
||||
.foreign-dot--codex {
|
||||
background: #9b8cff;
|
||||
}
|
||||
.foreign-dot--shell {
|
||||
background: #4caf7d;
|
||||
}
|
||||
|
||||
.foreign-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.foreign-row-name {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foreign-row-sub {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foreign-open-btn {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.22rem 0.6rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.foreign-open-btn:hover:not(:disabled) {
|
||||
border-color: var(--session-blue, #4a9eff);
|
||||
color: var(--session-blue, #4a9eff);
|
||||
}
|
||||
|
||||
.foreign-open-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.foreign-empty {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0.3rem 0.1rem;
|
||||
}
|
||||
|
||||
.foreign-notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.foreign-note {
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.85;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
|
||||
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
|
||||
.spreadsheet-preview-shell {
|
||||
|
||||
@@ -1149,8 +1149,8 @@
|
||||
|
||||
// A plain left-click reported to the CLI, the primary pane's desktop click
|
||||
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
|
||||
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
|
||||
// drag selects text), which leaves this xterm's own mouse encoder idle for
|
||||
// server strips the mouse DECSETs of some modes (so a drag selects text),
|
||||
// which leaves this xterm's own mouse encoder idle for
|
||||
// them; without this a click in such a pane never reached the CLI. Only
|
||||
// while this pane's session has tracking on (cliMouseTracking), through the
|
||||
// same skips as the primary pane. Bubble phase, as there. The target is
|
||||
|
||||
@@ -1572,8 +1572,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A real mouse click normally reaches the PTY through xterm's own mouse
|
||||
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
|
||||
// the server strips the enabling DECSETs from claude/codex/gemini output
|
||||
// (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode,
|
||||
// so a drag selects text) so the wheel keeps scrolling
|
||||
// (isAltScreenStripMode, session.ts) and from the mouse-strip modes'
|
||||
// (isMuxMouseStripMode, so a drag selects text) so the wheel keeps scrolling
|
||||
// scrollback. Desktop clicks therefore stopped reporting entirely (the
|
||||
// same breakage the mobile touchend tap branch above works around).
|
||||
// Hand-encode the SGR report for plain left-clicks on those sessions.
|
||||
@@ -2943,6 +2943,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (overlay) overlay.classList.remove('visible');
|
||||
this.hideHomeSessions?.();
|
||||
this.showMobileOverview();
|
||||
// The phone overview hosts the same list in its own container.
|
||||
this.wireForeignSessions?.();
|
||||
this.startForeignPolling?.();
|
||||
this._updateCjkInputState?.();
|
||||
return;
|
||||
}
|
||||
@@ -2957,6 +2960,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Open tabs down the left gutter. Self-gating: a window too narrow to hold
|
||||
// the column without overlapping the content leaves it hidden.
|
||||
this.showHomeSessions?.();
|
||||
// Sessions a human opened outside Codeman. Polls only while this screen is
|
||||
// up (stopped in hideWelcome), see foreign-sessions.js.
|
||||
this.wireForeignSessions?.();
|
||||
this.startForeignPolling?.();
|
||||
}
|
||||
// Home screen has no input target — hide the CJK textarea (activeSessionId
|
||||
// is null by the time we get here). Guarded: defined on the app object.
|
||||
@@ -2966,6 +2973,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
hideWelcome() {
|
||||
this.hideMobileOverview?.();
|
||||
this.hideHomeSessions?.();
|
||||
this.stopForeignPolling?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
@@ -5942,8 +5950,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* literal text (`[<0;88;20M`) and they garble the next line typed.
|
||||
*
|
||||
* Fails toward silence: an unknown or stale flag reports nothing rather than
|
||||
* injecting bytes. After a server restart the flag is false until the CLI
|
||||
* re-emits its DECSET, which closing and reopening a dialog does.
|
||||
* injecting bytes. After a server restart the flag is false until the DECSET
|
||||
* passes again, which tmux does itself when the server re-attaches to the pane.
|
||||
*
|
||||
* `sessionId` defaults to the primary pane's session; a TerminalTile passes
|
||||
* its own (through _handleDesktopTerminalClick's target), never the active one.
|
||||
@@ -6059,18 +6067,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
// option, but it would override an explicit user choice; instead the caller
|
||||
// falls through to _maybePageCliTranscript, so the gesture still pages the
|
||||
// CLI's transcript (or loads unloaded tmux scrollback, see there) and the
|
||||
// setting keeps meaning exactly what it says.
|
||||
// setting keeps meaning exactly what it says. Pi is the accepted exception:
|
||||
// it declares no `transcriptPageKeys`, so with this on its wheel stays dead.
|
||||
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
|
||||
const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode;
|
||||
if (mode && mode !== 'none') return false;
|
||||
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
// GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm
|
||||
// encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects
|
||||
// locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the
|
||||
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
|
||||
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
|
||||
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
|
||||
// GitHub Copilot CLI (and pi, fullscreen by default) scrolls its own transcript on SGR wheel
|
||||
// reports (checked with xterm encoding them natively). Their mouse DECSETs are stripped
|
||||
// server-side so a drag selects locally (strip-mux-and-mouse), which also means xterm no longer
|
||||
// encodes the wheel: send the same reports by hand, but only while the CLI is actually tracking
|
||||
// the mouse. Not gated on a version. On Copilot a stale-false flag falls through to the
|
||||
// PageUp/PageDown fallback like claude's; pi has none on purpose (the `transcriptPageKeys` note
|
||||
// in stock.ts), so there the wheel does nothing until the flag is back.
|
||||
if (sessionMode === 'copilot' || sessionMode === 'pi') return session?.cliMouseTracking === true;
|
||||
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
|
||||
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Only while Claude or Codex is actually listening for the mouse. Claude's
|
||||
@@ -6408,8 +6419,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* The reason is that the habit and xterm's Shift mean different things once
|
||||
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
|
||||
* the app actually has mouse tracking on; the server strips those DECSETs for
|
||||
* claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode),
|
||||
* so xterm's mouseTrackingMode is
|
||||
* claude/codex/gemini (isAltScreenStripMode) and the mouse-strip modes
|
||||
* (isMuxMouseStripMode), so xterm's mouseTrackingMode is
|
||||
* permanently `none`, that branch is unreachable, and Shift instead falls into
|
||||
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
|
||||
* when `selectionStart` is null, so the drag never anchors and no selection is
|
||||
@@ -6703,7 +6714,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
_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;
|
||||
if (!session || session.remote || session.docker || session.adopt || session.cliMouseTracking) return false;
|
||||
const modes = window.__codemanKeyboardHoldModes;
|
||||
return Array.isArray(modes) && modes.includes(session.mode || 'claude');
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "1.48.0",
|
||||
"version": "1.49.0",
|
||||
"highlights": [
|
||||
"Talk to your agents from Discord (#741)",
|
||||
"Edit history panel (#693)",
|
||||
"Custom endpoints go further (#752, #755, #757)",
|
||||
"Clone into a custom folder, and make a folder from the picker (#744, #745)",
|
||||
"Relative links follow your `cd` (#740)",
|
||||
"File links keep the whole name (#735)",
|
||||
"A failed settings save says so (#736)",
|
||||
"Phone keys show a press (#737)"
|
||||
"Open the tmux sessions you started yourself (#510)",
|
||||
"Install without administrator rights (#768)",
|
||||
"Docker Compose starts in two commands (#573)",
|
||||
"Codex sessions can drive other sessions (#753)",
|
||||
"Persian, Arabic and Hebrew read right to left (#715)",
|
||||
"Edit history for remote sessions (#766)",
|
||||
"The Files panel follows the agent into a git worktree (#772, #765)",
|
||||
"One big folder no longer hides the rest (#614)"
|
||||
]
|
||||
}
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
SettingsWriteRefusedError,
|
||||
settingsWriteErrorResponse,
|
||||
} from '../settings-writer.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -126,7 +127,6 @@ import {
|
||||
writeRemoteHosts,
|
||||
} from '../../remote-hosts.js';
|
||||
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||
@@ -212,11 +212,21 @@ function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentC
|
||||
};
|
||||
}
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
/**
|
||||
* Read and parse linked-cases.json, returning empty object on missing/invalid file. For reads only:
|
||||
* every write goes through `mutateLinkedCases()`, which re-reads the file inside its own critical
|
||||
* section and refuses one that does not parse.
|
||||
*/
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
return readJsonConfig<Record<string, string>>(linkedCasesPath(), 'linked cases', {});
|
||||
}
|
||||
|
||||
/** A conflict thrown from inside a `mutateLinkedCases()` callback: answered 409, nothing written. */
|
||||
function linkedConflict(message: string): Error {
|
||||
return Object.assign(new Error(message), { conflict: true });
|
||||
}
|
||||
const isLinkedConflict = (err: unknown): boolean => (err as { conflict?: boolean }).conflict === true;
|
||||
|
||||
/**
|
||||
* Resolve a case name to its directory path, checking linked cases first, then the
|
||||
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
|
||||
@@ -553,15 +563,11 @@ export function registerCaseRoutes(
|
||||
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
|
||||
await writeHooksConfig(casePath);
|
||||
|
||||
const codemanDir = getDataDir();
|
||||
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
|
||||
// Re-read right before writing, so a case linked since the check above is not dropped. This only
|
||||
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
|
||||
// requests that both read before either writes can still lose one entry.
|
||||
const fresh = await readLinkedCases();
|
||||
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
|
||||
fresh[name] = casePath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||
// The check above ran before the scaffold; a case linked since then wins and this call is undone.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" was just linked`);
|
||||
return { ...current, [name]: casePath };
|
||||
});
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
return { success: true, data: { case: { name, path: casePath } } };
|
||||
@@ -569,10 +575,14 @@ export function registerCaseRoutes(
|
||||
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
|
||||
// entries inside the empty folder the user picked; never anything else.
|
||||
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
|
||||
if ((err as { conflict?: boolean }).conflict) {
|
||||
if (isLinkedConflict(err)) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||
}
|
||||
if (err instanceof LinkedCasesWriteRefusedError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -748,7 +758,7 @@ export function registerCaseRoutes(
|
||||
? 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.`
|
||||
? `${customPath} already has files in it. The clone target must be empty or not exist yet.`
|
||||
: prepared.reason
|
||||
);
|
||||
}
|
||||
@@ -890,20 +900,20 @@ export function registerCaseRoutes(
|
||||
}
|
||||
|
||||
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.
|
||||
// The name was checked before git ran, which can take minutes. Checked again where the entry is
|
||||
// written, so of two clones that claimed one name only the first to finish links it.
|
||||
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));
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" was linked while the clone ran`);
|
||||
return { ...current, [name]: casePath };
|
||||
});
|
||||
} 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);
|
||||
const conflict = isLinkedConflict(err);
|
||||
const refused = err instanceof LinkedCasesWriteRefusedError;
|
||||
reply.code(conflict || refused ? 409 : 500);
|
||||
return createErrorResponse(
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : refused ? ApiErrorCode.CONFLICT : ApiErrorCode.OPERATION_FAILED,
|
||||
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
|
||||
);
|
||||
}
|
||||
@@ -1713,28 +1723,17 @@ export function registerCaseRoutes(
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||
}
|
||||
|
||||
// Load existing linked cases
|
||||
const linkedCases = await readLinkedCases();
|
||||
|
||||
// Check if name is already linked
|
||||
if (linkedCases[name]) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
`Case "${name}" is already linked to ${linkedCases[name]}`
|
||||
);
|
||||
}
|
||||
|
||||
// Save the linked case
|
||||
linkedCases[name] = expandedPath;
|
||||
// The name check and the write are one step, so two links of one name cannot both succeed.
|
||||
try {
|
||||
const codemanDir = getDataDir();
|
||||
if (!existsSync(codemanDir)) {
|
||||
mkdirSync(codemanDir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" is already linked to ${current[name]}`);
|
||||
return { ...current, [name]: expandedPath };
|
||||
});
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
||||
return { success: true, data: { case: { name, path: expandedPath } } };
|
||||
} catch (err) {
|
||||
if (isLinkedConflict(err)) return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
@@ -1815,13 +1814,19 @@ export function registerCaseRoutes(
|
||||
// must not unlink one either; skip so control falls through to their local delete.
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
// Removes this one entry from the registry as it is now, keeping any linked since the read above.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (!Object.hasOwn(current, name)) return undefined;
|
||||
const next = { ...current };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
|
||||
await forgetCaseDefault();
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
@@ -2007,7 +2012,6 @@ export function registerCaseRoutes(
|
||||
stopped++;
|
||||
}
|
||||
if (moveFolder) await moveCaseFolder(oldPath, newPath);
|
||||
const originalLinks = isLinked ? await readLinkedCases() : undefined;
|
||||
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
|
||||
// these keys alone, so a settings save that landed in between is kept.
|
||||
let settingsUndo: Record<string, unknown> | undefined;
|
||||
@@ -2027,16 +2031,19 @@ export function registerCaseRoutes(
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
}
|
||||
}
|
||||
if (originalLinks) {
|
||||
const links = { ...originalLinks };
|
||||
delete links[name];
|
||||
links[newName] = newPath;
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
|
||||
} catch (err) {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (isLinked) {
|
||||
// Checked again where the entry is written: the checks above ran before the sessions were
|
||||
// stopped and the folder moved. A failed write leaves the registry as it was.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[newName]) throw new CaseRenameError('EXISTS', `A case named "${newName}" already exists`);
|
||||
if (current[name] !== oldPath) {
|
||||
throw new CaseRenameError('BUSY', `Case "${name}" was changed while it was being renamed`);
|
||||
}
|
||||
const links = { ...current };
|
||||
delete links[name];
|
||||
links[newName] = newPath;
|
||||
return links;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// The folder moved but the registries did not follow: put settings.json and the folder back
|
||||
@@ -2081,6 +2088,9 @@ export function registerCaseRoutes(
|
||||
: ApiErrorCode.OPERATION_FAILED;
|
||||
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
|
||||
}
|
||||
if (err instanceof LinkedCasesWriteRefusedError) {
|
||||
return fail(409, ApiErrorCode.CONFLICT, err.message, { stoppedSessions: stopped });
|
||||
}
|
||||
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
|
||||
}
|
||||
let upload: { name: string; data: Uint8Array } | undefined;
|
||||
if (file !== undefined) {
|
||||
const problem = await fileProblem(session.workingDir, !!session.remote, file);
|
||||
const problem = await fileProblem(session.workingDir, !!session.remote || session.isAdopted, file);
|
||||
if (typeof problem === 'string') {
|
||||
refundNotifyToken(session.id);
|
||||
reply.code(400);
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* 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
|
||||
* background). A remote session is read over ssh and never woken; 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).
|
||||
*/
|
||||
@@ -86,7 +86,7 @@ function listOf(
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
|
||||
/** The answer for a session that is never read (Docker, non-claude): no store involved. */
|
||||
function unsupportedList(reason: string): EditHistoryList {
|
||||
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
|
||||
}
|
||||
@@ -99,6 +99,7 @@ export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort
|
||||
mode: session.mode,
|
||||
remote: session.remote,
|
||||
docker: session.docker,
|
||||
adopt: session.adopt,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
claudeConfigDir: session.claudeConfigDir(),
|
||||
};
|
||||
|
||||
+239
-104
@@ -8,7 +8,7 @@
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync } from 'node:fs';
|
||||
import { createReadStream, realpathSync, type Dirent } from 'node:fs';
|
||||
import type { Readable } from 'node:stream';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
@@ -782,6 +782,100 @@ export function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder entries one Files panel listing may read, summed over the folders it opens. The budget of
|
||||
* the walk caps what a listing RETURNS, but taking turns means opening every folder found so far,
|
||||
* so this is what caps the reading: past it, folders not opened yet stay unread and are reported as
|
||||
* unfinished. It also bounds what the walk holds, since a folder keeps only entries it has read. A
|
||||
* folder is read whole, so the one that crosses this line overshoots by its own size.
|
||||
*/
|
||||
const MAX_WALK_ENTRIES_READ = 250_000;
|
||||
|
||||
/** One folder of a Files panel walk, tree or search. */
|
||||
interface WalkCursor {
|
||||
dirPath: string;
|
||||
depth: number;
|
||||
/** What the listing can use of this folder, folders first. Unset until its first turn. */
|
||||
entries?: Dirent[];
|
||||
index: number;
|
||||
/** The read kept fewer entries than the folder lists, so running out of `entries` does not finish it. */
|
||||
more?: boolean;
|
||||
/** Its probe answered `unknown`: it may hold anything, so it must not pass for an empty folder. */
|
||||
unanswered?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* First turn of a folder: reads it and sets `cursor.entries` to the part the listing could use,
|
||||
* which is the entries it lists, sorted, up to what is left of the budget. Leaves `entries` unset
|
||||
* for a folder it could not read, and sets `unanswered` when that was a probe that established
|
||||
* nothing. Answers the number of entries read, for the walk's read ceiling.
|
||||
*/
|
||||
async function readWalkFolder(
|
||||
cursor: WalkCursor,
|
||||
budgetLeft: number,
|
||||
isListed: (entry: Dirent) => boolean
|
||||
): Promise<number> {
|
||||
// The walk's root is the session's own folder. Below it any folder can be a mount that stopped
|
||||
// answering, and a bulk scan keeps the stall cap (#516): an unbounded readdir there would hold a
|
||||
// threadpool worker for good. `unknown` is never `absent`, so only a folder the filesystem says
|
||||
// is gone drops out quietly: one that did not answer goes on the walk's unfinished list.
|
||||
if (cursor.depth > 1) {
|
||||
const kind = await probePathKind(cursor.dirPath);
|
||||
cursor.unanswered = kind === 'unknown';
|
||||
if (kind !== 'directory') return 0;
|
||||
}
|
||||
|
||||
let read: Dirent[];
|
||||
try {
|
||||
read = await fs.readdir(cursor.dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
// Can't read directory (permission denied, etc.).
|
||||
return 0;
|
||||
}
|
||||
const listed = read.filter(isListed).sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
cursor.more = listed.length > budgetLeft;
|
||||
cursor.entries = cursor.more ? listed.slice(0, budgetLeft) : listed;
|
||||
return read.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a folder's turn ends, given a `share` of the budget. Folders sort first, and each one found
|
||||
* here waits for a later round, by which time a folder that spent the rest of its share on its own
|
||||
* files has left nothing to read it with. So those files count as one more folder: they take an
|
||||
* equal part of what the subfolders left and the remainder stays in the budget. A turn that found
|
||||
* no subfolder has nothing to hold back for, and a turn always covers at least one entry.
|
||||
*/
|
||||
function walkTurnEnd(entries: Dirent[], start: number, share: number): number {
|
||||
const end = Math.min(start + share, entries.length);
|
||||
let firstFile = start;
|
||||
while (firstFile < end && entries[firstFile].isDirectory()) firstFile++;
|
||||
return firstFile + Math.floor((end - firstFile) / (firstFile - start + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `root=live` listing may start at a session's `liveWorktree` (#765). `findLiveWorktree()`
|
||||
* judged that path lexically when the pane moved, and a symlink can take its place, or a parent's,
|
||||
* without moving the pane. The probe and `readdir` both follow one, and the walk's `path`s only look
|
||||
* relative to `workingDir`, so this is the request-time half of the rule, the same one
|
||||
* `validateSessionFilePath()` applies to a file: resolved, the worktree must lie strictly inside the
|
||||
* resolved `workingDir`. Only the CHECK uses the resolved paths: the walk keeps the session's spelling,
|
||||
* or a workspace reached through a symlink would stop producing paths relative to `workingDir`.
|
||||
* Probed first (#516); both realpaths then walk the components that probe has just answered for.
|
||||
*/
|
||||
async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Promise<boolean> {
|
||||
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
|
||||
try {
|
||||
const [realRoot, realWorktree] = await Promise.all([fs.realpath(workingDir), fs.realpath(worktreePath)]);
|
||||
return realWorktree !== realRoot && isPathWithinRoot(realRoot, realWorktree);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||
return roots
|
||||
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||
@@ -1480,7 +1574,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
// 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.
|
||||
// deleting would take the linked folder with. A case there is made through Add Case > Create New with no
|
||||
// custom folder. The refusal has to read right from every picker that offers New folder: a Create New or
|
||||
// Clone Repo user is already on that tab, with the custom folder ticked, and this is where Browse opens
|
||||
// while the field is empty.
|
||||
const casesDirs = await Promise.all(
|
||||
[...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) =>
|
||||
fs.realpath(dir).catch(() => resolve(dir))
|
||||
@@ -1490,7 +1587,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
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.'
|
||||
'This is a cases folder: every folder in it is a case. Pick a folder outside it, or make the case with Add Case > Create New or Clone Repo (no custom folder).'
|
||||
);
|
||||
}
|
||||
// Like browse, a file is not a place to create in.
|
||||
@@ -1595,12 +1692,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
|
||||
const { depth, showHidden, q, root } = req.query as {
|
||||
depth?: string;
|
||||
showHidden?: string;
|
||||
q?: string;
|
||||
root?: string;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
const workingDir = session.workingDir;
|
||||
// `root=live` starts the walk at the linked worktree the pane is in (#765), once that folder
|
||||
// proves to be inside `workingDir` right now, and every `path` below stays relative to
|
||||
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
|
||||
// paths against the same root as any other. Only the search predicate reads a path relative
|
||||
// to the listing, since that is the tree the person is looking at.
|
||||
const liveWorktree = session.liveWorktree;
|
||||
const walkRoot =
|
||||
root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
|
||||
? liveWorktree.path
|
||||
: workingDir;
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
const matcher = compileFileQuery(q ?? '');
|
||||
@@ -1630,12 +1742,17 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
size?: number;
|
||||
extension?: string;
|
||||
children?: FileTreeNode[];
|
||||
/** Set only on a folder the walk stopped short of or could not reach, so `children` is not all of it. */
|
||||
incomplete?: boolean;
|
||||
}
|
||||
|
||||
let totalFiles = 0;
|
||||
let totalDirectories = 0;
|
||||
let truncated = false;
|
||||
const maxFiles = 5000;
|
||||
let entriesRead = 0;
|
||||
const isListed = (entry: Dirent): boolean =>
|
||||
(includeHidden || !entry.name.startsWith('.')) && !(entry.isDirectory() && excludeDirs.has(entry.name));
|
||||
|
||||
// ===== Search mode =====
|
||||
// A query turns this endpoint into a FLAT match list rather than a nested
|
||||
@@ -1647,70 +1764,83 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const matches: FileTreeNode[] = [];
|
||||
const maxMatches = 1000;
|
||||
|
||||
const searchDirectory = async (dirPath: string, currentDepth: number): Promise<void> => {
|
||||
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
const queue: WalkCursor[] = [{ dirPath: walkRoot, depth: 1, index: 0 }];
|
||||
// Folders the read ceiling kept shut or that did not answer. What was already read still gets its turns.
|
||||
const unread: WalkCursor[] = [];
|
||||
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
|
||||
// Per-directory round-robin (breadth-first): each queued directory gets an equal share of what
|
||||
// is left before any of them gets more, so results arrive shallow-first. Fair per directory,
|
||||
// not per subtree: a folder of thousands of small subfolders queues thousands of directories
|
||||
// and can still take most of the budget one level down. Reading a folder costs the same
|
||||
// whatever its share, so this never bounds the reading: MAX_WALK_ENTRIES_READ does.
|
||||
const directoriesInRound = queue.length;
|
||||
const nodesPerDirectory = Math.max(
|
||||
1,
|
||||
Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound)
|
||||
);
|
||||
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
// Can't read directory (permission denied, etc.)
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
|
||||
for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) {
|
||||
// Spent part way through a round: the rest stay queued unread rather than sorted for nothing.
|
||||
if (totalFiles + totalDirectories >= maxFiles || matches.length >= maxMatches) break;
|
||||
const cursor = queue.shift();
|
||||
if (!cursor) break;
|
||||
if (cursor.depth > maxDepth) {
|
||||
truncated = true;
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
if (!includeHidden && entry.name.startsWith('.')) continue;
|
||||
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
if (!cursor.entries) {
|
||||
if (entriesRead >= MAX_WALK_ENTRIES_READ) {
|
||||
unread.push(cursor);
|
||||
continue;
|
||||
}
|
||||
// Always recurse, even when this directory does not match.
|
||||
await searchDirectory(fullPath, currentDepth + 1);
|
||||
} else {
|
||||
totalFiles++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(fullPath)).size;
|
||||
} catch {
|
||||
// Skip size if we can't stat the match.
|
||||
entriesRead += await readWalkFolder(cursor, maxFiles - totalFiles - totalDirectories, isListed);
|
||||
if (cursor.unanswered) unread.push(cursor);
|
||||
if (!cursor.entries) continue;
|
||||
}
|
||||
|
||||
const endIndex = walkTurnEnd(cursor.entries, cursor.index, nodesPerDirectory);
|
||||
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
|
||||
const entry = cursor.entries[cursor.index++];
|
||||
const fullPath = join(cursor.dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
const listedPath = relative(walkRoot, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
}
|
||||
// Queued, not entered: it waits for a later round with every other directory found.
|
||||
queue.push({ dirPath: fullPath, depth: cursor.depth + 1, index: 0 });
|
||||
} else {
|
||||
totalFiles++;
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(fullPath)).size;
|
||||
} catch {
|
||||
// Skip size if we can't stat the match.
|
||||
}
|
||||
matches.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: 'file',
|
||||
size,
|
||||
extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
|
||||
});
|
||||
}
|
||||
matches.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: 'file',
|
||||
size,
|
||||
extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (cursor.index < cursor.entries.length || cursor.more) queue.push(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
await searchDirectory(workingDir, 1);
|
||||
}
|
||||
if (queue.length > 0 || unread.length > 0) truncated = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
@@ -1723,47 +1853,57 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
}
|
||||
|
||||
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<FileTreeNode[]> => {
|
||||
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
|
||||
truncated = true;
|
||||
return [];
|
||||
}
|
||||
interface TreeDirectoryCursor extends WalkCursor {
|
||||
/** This directory's node. The root of the walk has none: `tree` is its list of children. */
|
||||
node?: FileTreeNode;
|
||||
nodes: FileTreeNode[];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const nodes: FileTreeNode[] = [];
|
||||
const tree: FileTreeNode[] = [];
|
||||
const queue: TreeDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, nodes: tree, index: 0 }];
|
||||
const unread: TreeDirectoryCursor[] = [];
|
||||
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles) {
|
||||
// The same per-directory round-robin as the search walk above, with the same limits.
|
||||
const directoriesInRound = queue.length;
|
||||
const nodesPerDirectory = Math.max(
|
||||
1,
|
||||
Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound)
|
||||
);
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (totalFiles + totalDirectories > maxFiles) {
|
||||
truncated = true;
|
||||
break;
|
||||
for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) {
|
||||
// Before the shift, so a cursor this skips stays queued and is flagged below.
|
||||
if (totalFiles + totalDirectories >= maxFiles) break;
|
||||
const cursor = queue.shift();
|
||||
if (!cursor) break;
|
||||
if (cursor.depth > maxDepth) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (!cursor.entries) {
|
||||
if (entriesRead >= MAX_WALK_ENTRIES_READ) {
|
||||
unread.push(cursor);
|
||||
continue;
|
||||
}
|
||||
entriesRead += await readWalkFolder(cursor, maxFiles - totalFiles - totalDirectories, isListed);
|
||||
if (cursor.unanswered) unread.push(cursor);
|
||||
if (!cursor.entries) continue;
|
||||
}
|
||||
|
||||
// Skip hidden files unless requested
|
||||
if (!includeHidden && entry.name.startsWith('.')) continue;
|
||||
|
||||
// Skip excluded directories
|
||||
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const endIndex = walkTurnEnd(cursor.entries, cursor.index, nodesPerDirectory);
|
||||
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) {
|
||||
const entry = cursor.entries[cursor.index++];
|
||||
const fullPath = join(cursor.dirPath, entry.name);
|
||||
const relativePath = fullPath.slice(workingDir.length + 1);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
const children = await scanDirectory(fullPath, currentDepth + 1);
|
||||
nodes.push({
|
||||
const node: FileTreeNode = {
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: 'directory',
|
||||
children,
|
||||
});
|
||||
children: [],
|
||||
};
|
||||
cursor.nodes.push(node);
|
||||
queue.push({ dirPath: fullPath, depth: cursor.depth + 1, node, nodes: node.children!, index: 0 });
|
||||
} else {
|
||||
totalFiles++;
|
||||
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined;
|
||||
@@ -1772,31 +1912,26 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const stat = await fs.stat(fullPath);
|
||||
size = stat.size;
|
||||
} catch {
|
||||
// Skip if can't stat
|
||||
// Skip if can't stat.
|
||||
}
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
type: 'file',
|
||||
size,
|
||||
extension: ext,
|
||||
});
|
||||
cursor.nodes.push({ name: entry.name, path: relativePath, type: 'file', size, extension: ext });
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
} catch {
|
||||
// Can't read directory (permission denied, etc.)
|
||||
return [];
|
||||
if (cursor.index < cursor.entries.length || cursor.more) queue.push(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const tree = await scanDirectory(workingDir, 1);
|
||||
}
|
||||
// Every directory still queued is unread or half read, and an empty `children` would read as an
|
||||
// empty folder. The root has no node to flag: `truncated` says the same for it.
|
||||
for (const cursor of [...queue, ...unread]) {
|
||||
if (cursor.node) cursor.node.incomplete = true;
|
||||
}
|
||||
if (queue.length > 0 || unread.length > 0) truncated = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
|
||||
@@ -81,6 +81,8 @@ export function registerGitStatusRoutes(
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
|
||||
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
|
||||
// An adopted wrapper's folder is the neutral `/tmp`, so a scan would run git on whatever sits there.
|
||||
if (session.isAdopted) return { success: true, data: emptyOverview('unsupported', { reason: 'adopted' }) };
|
||||
return {
|
||||
success: true,
|
||||
data: await getGitWorkspaceOverview(session.workingDir, {
|
||||
@@ -103,9 +105,12 @@ export function registerGitStatusRoutes(
|
||||
const { repo, path, kind } = query;
|
||||
const limits = limitsFrom(query);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.remote || session.docker) {
|
||||
if (session.remote || session.docker || session.isAdopted) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Git is not available for remote, Docker or adopted sessions'
|
||||
);
|
||||
}
|
||||
const repoRoot = repo
|
||||
? await findWorkspaceRepo(session.workingDir, repo, {
|
||||
@@ -159,6 +164,7 @@ export function registerGitStatusRoutes(
|
||||
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' }) };
|
||||
if (session.isAdopted) return { success: true, data: emptyReport('unsupported', { reason: 'adopted' }) };
|
||||
// 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.
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* @fileoverview Mux (tmux) session management routes.
|
||||
* Provides mux session listing, killing, reconciliation, and stats control.
|
||||
* Provides mux session listing, killing, reconciliation, stats control, and
|
||||
* discovery of FOREIGN tmux sessions (ones a human started outside Codeman).
|
||||
*
|
||||
* Discovery lives here rather than beside the adopt endpoint on purpose: like
|
||||
* every other route in this file it exposes cross-user process state (other
|
||||
* people's session names, commands and working directories), so it inherits the
|
||||
* admin gate this file already applies. Adoption is a session CREATE and stays in
|
||||
* `session-routes.ts`, where the owner, capacity and case-space gates live.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { InfraPort } from '../ports/index.js';
|
||||
import type { InfraPort, SessionPort } from '../ports/index.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { requireAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js';
|
||||
import { adoptTargetIdentity, foreignTargetKey } from '../../foreign-tmux.js';
|
||||
import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js';
|
||||
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort & SessionPort): void {
|
||||
app.get('/api/mux-sessions', async (req, reply) => {
|
||||
// Multi-user: this recovery/debug surface exposes every user's tmux + workdirs → admin-only
|
||||
// (requireAdmin is a no-op allow-all in single-user mode, so flag-off is unchanged).
|
||||
@@ -36,6 +46,57 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Foreign tmux sessions available for adoption.
|
||||
*
|
||||
* LOCAL results are always included and are TTL-cached, because the home screen
|
||||
* polls this endpoint while it is open. DOCKER and REMOTE are opt-in per
|
||||
* request (`?docker=1`, `?remote=1`): each costs one `docker exec` or one ssh
|
||||
* per target, and having the home page fan those out on every load is the one
|
||||
* cost this design refuses to pay.
|
||||
*
|
||||
* `adoptedBy` is filled from the live sessions, so a target Codeman already wraps
|
||||
* renders as "open" rather than offering a second wrapper. Sessions and not mux
|
||||
* records: the tmux record only exists once the tab's first attach starts the pane.
|
||||
*/
|
||||
app.get('/api/mux/foreign', async (req, reply) => {
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
const q = (req.query ?? {}) as Record<string, string | undefined>;
|
||||
const wantDocker = q.docker === '1' || q.docker === 'true';
|
||||
const wantRemote = q.remote === '1' || q.remote === 'true';
|
||||
|
||||
// Read the registries either way: `canScanWide` tells the browser whether the
|
||||
// expensive scan has anywhere to go. Without it the UI hides an empty block,
|
||||
// and with it the toggle that is the ONLY way to populate that block, which on
|
||||
// a host with containers but no local tmux sessions made the feature invisible.
|
||||
const dockerCases = await readAllDockerCases();
|
||||
const remoteHosts = await readAllRemoteHosts();
|
||||
|
||||
const result = await discoverForeignSessions({
|
||||
local: true,
|
||||
force: q.force === '1',
|
||||
dockerCases: wantDocker ? dockerCases : undefined,
|
||||
remoteHosts: wantRemote ? remoteHosts : undefined,
|
||||
});
|
||||
|
||||
// The same key the adopt route's one-wrapper check reads (see foreignTargetKey).
|
||||
const wrapped = new Map<string, string>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.adopt) wrapped.set(foreignTargetKey(adoptTargetIdentity(session.adopt)), session.id);
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: result.sessions.map((f) => ({
|
||||
...f,
|
||||
adoptedBy: wrapped.get(foreignTargetKey(f)),
|
||||
})),
|
||||
scannedAt: result.scannedAt,
|
||||
notes: result.notes,
|
||||
pollIntervalMs: FOREIGN_POLL_INTERVAL_MS,
|
||||
canScanWide: dockerCases.length > 0 || remoteHosts.length > 0,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/mux-sessions/stats/start', async (req, reply) => {
|
||||
// Multi-user: process-wide stats collection toggle → admin-only.
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
|
||||
@@ -37,6 +37,15 @@ import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from
|
||||
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
|
||||
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* An adopted session's `workingDir` is the neutral wrapper folder `/tmp`, not a workspace.
|
||||
* Using it would write a predictable name into a folder anyone can write to, and read one
|
||||
* back, while the agent in the pane never sees either file.
|
||||
*/
|
||||
function refuseAdoptedRalphFile(file: string) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${file} is not available for an adopted session`);
|
||||
}
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
@@ -60,6 +69,17 @@ export function registerRalphRoutes(
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
|
||||
// session can be `mode: 'claude'` and still be a process we never launched.
|
||||
// Everything below drives the pane on the assumption Codeman owns what runs
|
||||
// in it (sending `/clear`, killing and relaunching the agent), which against
|
||||
// someone else's live session is destructive, not merely unsupported.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'The Ralph tracker is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
|
||||
);
|
||||
}
|
||||
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(
|
||||
@@ -197,6 +217,7 @@ 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);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
|
||||
// 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.
|
||||
@@ -233,6 +254,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -280,6 +302,7 @@ 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);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@ralph_prompt.md');
|
||||
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
|
||||
if (session.remote) {
|
||||
return createErrorResponse(
|
||||
|
||||
@@ -91,8 +91,9 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort
|
||||
const transcriptPath = ctx.getTranscriptPath(id);
|
||||
const transcript = transcriptPath ? await readTranscriptSignals(transcriptPath) : null;
|
||||
// Remote-SSH cases skip git: workingDir is not local. Docker cases are
|
||||
// fine (the workspace is bind-mounted at the same host path).
|
||||
const workspace = session.remote ? null : await collectWorkspaceSignals(session.workingDir);
|
||||
// fine (the workspace is bind-mounted at the same host path). An adopted
|
||||
// session's workingDir is a neutral wrapper folder, so it skips git too.
|
||||
const workspace = session.remote || session.isAdopted ? null : await collectWorkspaceSignals(session.workingDir);
|
||||
|
||||
const lastPromptTs = profile.recentPrompts[profile.recentPrompts.length - 1]?.ts;
|
||||
const tracker = ctx.runSummaryTrackers.get(id);
|
||||
|
||||
@@ -98,6 +98,17 @@ export function registerRespawnRoutes(
|
||||
}
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
|
||||
// session can be `mode: 'claude'` and still be a process we never launched.
|
||||
// Everything below drives the pane on the assumption Codeman owns what runs
|
||||
// in it (sending `/clear`, killing and relaunching the agent), which against
|
||||
// someone else's live session is destructive, not merely unsupported.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
|
||||
);
|
||||
}
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
|
||||
@@ -241,6 +252,17 @@ export function registerRespawnRoutes(
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
}
|
||||
|
||||
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
|
||||
// session can be `mode: 'claude'` and still be a process we never launched.
|
||||
// Everything below drives the pane on the assumption Codeman owns what runs
|
||||
// in it (sending `/clear`, killing and relaunching the agent), which against
|
||||
// someone else's live session is destructive, not merely unsupported.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
|
||||
);
|
||||
}
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
|
||||
@@ -310,6 +332,17 @@ export function registerRespawnRoutes(
|
||||
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
|
||||
// session can be `mode: 'claude'` and still be a process we never launched.
|
||||
// Everything below drives the pane on the assumption Codeman owns what runs
|
||||
// in it (sending `/clear`, killing and relaunching the agent), which against
|
||||
// someone else's live session is destructive, not merely unsupported.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
|
||||
);
|
||||
}
|
||||
// Respawn is not supported for external-CLI sessions (opencode/codex)
|
||||
if (isExternalCliMode(session.mode)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
|
||||
|
||||
@@ -34,6 +34,17 @@ import {
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
} from '../../types.js';
|
||||
import { AdoptForeignSessionSchema } from '../schemas.js';
|
||||
import {
|
||||
discoverForeignSessions,
|
||||
invalidateForeignCache,
|
||||
readAllDockerCases,
|
||||
readAllRemoteHosts,
|
||||
} from '../../foreign-tmux-discovery.js';
|
||||
import { adoptTargetIdentity, foreignTargetKey, foreignViewSessionName } from '../../foreign-tmux.js';
|
||||
import { requireAdmin } from '../route-helpers.js';
|
||||
import type { SessionAdopt } from '../../types/session.js';
|
||||
import type { ForeignTmuxSession } from '../../types/foreign-tmux.js';
|
||||
import {
|
||||
GuardedInputBusyError,
|
||||
Session,
|
||||
@@ -202,7 +213,7 @@ import {
|
||||
} from '../../docker-hosts.js';
|
||||
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
||||
import { describeUnknownPath, probePathKind, type PathProbeOptions } from '../../utils/index.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
@@ -235,27 +246,35 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
|
||||
* 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.
|
||||
* synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, any future
|
||||
* `linkFromRealHome` recipe, and claude's `CLAUDE_CONFIG_DIR` for `projects`). 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.
|
||||
* path is never touched. Any other entry's lookup passes through untouched. Boot recovery
|
||||
* passes `probeOptions` of `{}`: it walks every saved session, so it keeps the bulk cap.
|
||||
*/
|
||||
export async function guardSessionConfigDirHome(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
baseLookup: (envVar: string) => string | undefined
|
||||
baseLookup: (envVar: string) => string | undefined,
|
||||
probeOptions: PathProbeOptions = { pastCap: true }
|
||||
): Promise<(envVar: string) => string | undefined> {
|
||||
const recipe = entry.capabilities.customModelInjection;
|
||||
if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup;
|
||||
const dirEnvVar = recipe.dirEnvVar;
|
||||
const dirEnvVar =
|
||||
recipe?.kind === 'env'
|
||||
? recipe.configDirVar
|
||||
: recipe?.kind === 'configDir' && recipe.linkFromRealHome?.length
|
||||
? recipe.dirEnvVar
|
||||
: undefined;
|
||||
if (!dirEnvVar) return baseLookup;
|
||||
const supplied = baseLookup(dirEnvVar)?.trim();
|
||||
if (!supplied || !isAbsolute(supplied)) return baseLookup;
|
||||
if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') {
|
||||
if ((await probePathKind(supplied, probeOptions)) === 'unknown') {
|
||||
console.warn(
|
||||
`[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead`
|
||||
);
|
||||
@@ -332,7 +351,7 @@ const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[h
|
||||
* - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs.
|
||||
* xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping
|
||||
* saved lines, so history disappeared on tab switch.
|
||||
* - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs.
|
||||
* - `strip-mux-and-mouse` (tmux-backed): alt-screen toggles and mouse DECSETs.
|
||||
* A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI
|
||||
* instead of selecting text (and Ctrl+C without a selection is opencode's app_exit).
|
||||
* `3J` stays: a TUI is not a `clear` consumer.
|
||||
@@ -1377,8 +1396,9 @@ export function registerSessionRoutes(
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and
|
||||
// effort today) fill the CLI's own config object where the caller left it unset.
|
||||
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model,
|
||||
// effort and network access today) fill the CLI's own config object where the caller
|
||||
// left it unset.
|
||||
// Local launches only: a remote attach runs whatever the remote pane already runs.
|
||||
const launchBody = remote ? body : await applyLaunchDefaults(mode, body);
|
||||
// Where a model override comes from is a capability, and the three answers are
|
||||
@@ -1487,6 +1507,187 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Adopt a foreign tmux session ==========
|
||||
|
||||
/**
|
||||
* The wrapper pane for an adopted session runs `tmux attach`, never the agent,
|
||||
* so it needs no workspace, and must not borrow the foreign one. Same value
|
||||
* `resolveMuxAttachCwd()` gives remote and docker panes.
|
||||
*/
|
||||
const ADOPTED_WRAPPER_WORKING_DIR = '/tmp';
|
||||
|
||||
/**
|
||||
* Targets with a wrapper being created right now. The live-session check alone
|
||||
* leaves a gap: between it and `addSession` the route awaits the host registries,
|
||||
* the history config and the owner's layout lock, so two devices opening one row
|
||||
* could each pass it.
|
||||
*/
|
||||
const adoptsInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a
|
||||
* Codeman session, so it appears as a tab and can be driven from the browser.
|
||||
*
|
||||
* Four things make this safe, and each is load-bearing:
|
||||
*
|
||||
* 1. **The body carries only an opaque id.** The socket path, session name and
|
||||
* host are re-resolved by re-running discovery here. A browser therefore
|
||||
* never supplies a fragment of the command we are about to run, which is the
|
||||
* same rule that keeps docker-adopt and remote-attach injection-free.
|
||||
* 2. **The candidate must still exist.** Discovery is re-run rather than cached,
|
||||
* so a session that died between the listing and the click fails with a 404
|
||||
* instead of producing a wrapper attached to nothing.
|
||||
* 3. **One wrapper per target.** Two wrappers on one foreign session would each
|
||||
* create their own grouped view and each think they own the tab; the guard
|
||||
* is here rather than in the button's in-flight lock, which only stops a
|
||||
* double-click on one device.
|
||||
* 4. **Admin-only under multi-user.** Discovery already is (it exposes other
|
||||
* users' processes), and adopting someone's `shell` is arbitrary execution
|
||||
* as the server account, which is exactly what the `can-bypass-permissions`
|
||||
* grant gates elsewhere. The admin gate subsumes it, so there is deliberately
|
||||
* no second grant check here.
|
||||
*/
|
||||
app.post('/api/sessions/adopt', async (req, reply) => {
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
|
||||
const owner = ownerFor(req);
|
||||
const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body');
|
||||
|
||||
// Re-resolve rather than trust: point 1 and 2 above.
|
||||
const found = await discoverForeignSessions({
|
||||
local: true,
|
||||
force: true,
|
||||
dockerCases: body.docker ? await readAllDockerCases() : undefined,
|
||||
remoteHosts: body.remote ? await readAllRemoteHosts() : undefined,
|
||||
});
|
||||
const target = found.sessions.find((f) => f.id === body.id);
|
||||
if (!target) {
|
||||
// ⚠️ "Not in the re-resolve" has two very different causes and they must not
|
||||
// be reported as one. The session really being gone is the ordinary case;
|
||||
// the OTHER case is a location we could not reach this time, which on a
|
||||
// flaky link makes a perfectly live remote session read as deleted. Measured
|
||||
// against a real VM whose ssh path dropped ~10% of connections: clicking
|
||||
// Open failed with "no longer there" while the session was sitting right
|
||||
// there. Discovery already knows which it was: it wrote a note, so say so.
|
||||
const reach = found.notes.filter((n) => !/skipped/.test(n));
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.NOT_FOUND,
|
||||
reach.length
|
||||
? `Could not reach it just now (${reach.join('; ')}). It may still be running, try again.`
|
||||
: 'That tmux session is no longer there. Refresh the list and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
// Point 3: one wrapper per target. Live sessions and not mux records, which
|
||||
// only exist once the first attach starts the pane, so reading those let a
|
||||
// second adopt through before the first tab had started.
|
||||
const targetKey = foreignTargetKey(target);
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.adopt && foreignTargetKey(adoptTargetIdentity(session.adopt)) === targetKey) {
|
||||
return { session: ctx.getSessionStateWithRespawn(session), alreadyAdopted: true };
|
||||
}
|
||||
}
|
||||
if (adoptsInFlight.has(targetKey)) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'That tmux session is already being opened');
|
||||
}
|
||||
|
||||
// After the reuse above, which creates nothing and so must not be refused at capacity.
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
adoptsInFlight.add(targetKey);
|
||||
try {
|
||||
return await createAdoptedSession(req, target, owner, body);
|
||||
} finally {
|
||||
adoptsInFlight.delete(targetKey);
|
||||
}
|
||||
});
|
||||
|
||||
/** Everything past the guards in `POST /api/sessions/adopt`, held under its in-flight mark. */
|
||||
async function createAdoptedSession(
|
||||
req: FastifyRequest,
|
||||
target: ForeignTmuxSession,
|
||||
owner: string | undefined,
|
||||
body: { name?: string; parentSessionId?: string }
|
||||
) {
|
||||
// Connection facts are copied onto the session rather than referenced by id:
|
||||
// a wrapper restored after a server restart must be able to rebuild its
|
||||
// command even if the host registry was edited in the meantime.
|
||||
const adopt: SessionAdopt = {
|
||||
location: target.location,
|
||||
socketPath: target.socketPath,
|
||||
targetSession: target.sessionName,
|
||||
viewSession: '',
|
||||
paneCurrentPath: target.workingDir,
|
||||
};
|
||||
|
||||
if (target.location === 'docker') {
|
||||
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((h) => h.id === target.hostId);
|
||||
if (!target.containerName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Container name missing for a docker candidate');
|
||||
}
|
||||
adopt.docker = {
|
||||
hostId: target.hostId ?? '',
|
||||
label: target.hostLabel ?? target.containerName,
|
||||
engine: host?.engine ?? 'docker',
|
||||
containerName: target.containerName,
|
||||
daemonHost: host?.daemonHost,
|
||||
context: host?.context,
|
||||
};
|
||||
} else if (target.location === 'remote') {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((h) => h.id === target.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
adopt.remote = {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
identityFile: host.identityFile,
|
||||
socksProxy: host.socksProxy,
|
||||
jumpHost: host.jumpHost,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
const adoptHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
|
||||
// ⚠️ The wrapper's `workingDir` is a NEUTRAL LOCAL path, never the foreign
|
||||
// pane's cwd. That cwd is an observation about another machine: it can carry
|
||||
// characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which
|
||||
// makes `createSession` throw so the tab never starts at all, and when it is a
|
||||
// container or ssh path every local consumer of `workingDir` (the Files panel,
|
||||
// the boot hook sweep, the fs watchers) reads it as a path on THIS host. The
|
||||
// foreign cwd stays on `adopt.paneCurrentPath`, which is for display only.
|
||||
// `/tmp` is the same neutral cwd `resolveMuxAttachCwd` already gives the pane.
|
||||
const session = new Session({
|
||||
workingDir: ADOPTED_WRAPPER_WORKING_DIR,
|
||||
mode: target.mode,
|
||||
name: body.name || target.sessionName,
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
tmuxHistoryLimit: adoptHistoryConfig.tmuxHistoryLimit,
|
||||
adopt,
|
||||
owner,
|
||||
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
|
||||
});
|
||||
// The view session name is derived from the Codeman session id, so it can only
|
||||
// be filled once the Session exists.
|
||||
adopt.viewSession = foreignViewSessionName(session.id);
|
||||
|
||||
await ctx.addSession(session);
|
||||
ctx.store.incrementSessionsCreated();
|
||||
ctx.persistSessionState(session);
|
||||
await ctx.setupSessionListeners(session);
|
||||
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
||||
invalidateForeignCache();
|
||||
|
||||
const lightState = ctx.getSessionStateWithRespawn(session);
|
||||
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
||||
return { session: lightState, adopted: true };
|
||||
}
|
||||
|
||||
// ========== Rename Session ==========
|
||||
|
||||
app.put('/api/sessions/:id/name', async (req) => {
|
||||
@@ -1550,6 +1751,13 @@ export function registerSessionRoutes(
|
||||
'Custom model endpoints are not supported for remote (SSH) or Docker sessions yet'
|
||||
);
|
||||
}
|
||||
// Its own check: this restarts the CLI in the pane, and an adopted pane's CLI is not ours to restart.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Custom model endpoints are not available for adopted sessions: Codeman did not start this agent'
|
||||
);
|
||||
}
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
}
|
||||
@@ -1675,7 +1883,7 @@ export function registerSessionRoutes(
|
||||
session.id,
|
||||
contextLength,
|
||||
session.workingDir,
|
||||
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
|
||||
await guardSessionConfigDirHome(entry, (envVar) => session.ownEnvValue(envVar))
|
||||
);
|
||||
if (!applied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||
@@ -1689,6 +1897,7 @@ export function registerSessionRoutes(
|
||||
envKeys: applied.envKeys,
|
||||
configDir: applied.configDir,
|
||||
launchModel: applied.launchModel,
|
||||
homeEnvVar: applied.homeEnvVar,
|
||||
},
|
||||
applied.envOverrides
|
||||
);
|
||||
@@ -1861,6 +2070,15 @@ export function registerSessionRoutes(
|
||||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// Its own check: `runPrompt` starts a new local claude, and the answer has to say so
|
||||
// here rather than as a `session:error` after a success.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'One-shot prompts are not available for adopted sessions: Codeman did not start this agent'
|
||||
);
|
||||
}
|
||||
|
||||
if (session.isBusy()) {
|
||||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||||
}
|
||||
@@ -1908,6 +2126,10 @@ export function registerSessionRoutes(
|
||||
// and belongs in its own PR, not in a refactor that is meant to change nothing.
|
||||
if (
|
||||
!isExternalCliMode(session.mode) &&
|
||||
// ⚠️ And never for an adopted session, whose mode can be claude while the
|
||||
// agent in that pane belongs to someone else. Kept as its own clause for
|
||||
// the same reason the respawn and Ralph routes keep theirs separate.
|
||||
!session.isAdopted &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -2846,7 +3068,13 @@ export function registerSessionRoutes(
|
||||
* effort: the tab rename has already happened and must not fail on this.
|
||||
*/
|
||||
async function syncClaudeTitle(session: Session): Promise<void> {
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || session.remote || session.docker) return;
|
||||
if (
|
||||
getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' ||
|
||||
session.remote ||
|
||||
session.docker ||
|
||||
session.isAdopted
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const hookPath = ctx.getTranscriptPath(session.id);
|
||||
@@ -2924,6 +3152,17 @@ export function registerSessionRoutes(
|
||||
// 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) => {
|
||||
// ⚠️ An adopted session has no transcript Codeman can name. Every reader below pairs one by
|
||||
// the id Codeman launched the CLI with or by the session's folder, and neither means anything
|
||||
// here: the CLI chose its own id, and the folder is the neutral `/tmp`, so the newest file for
|
||||
// that folder would be another conversation's answer shown as this one's. Nothing said yet is
|
||||
// the honest answer.
|
||||
if (session.isAdopted) {
|
||||
return context === 'full' || context === 'turn'
|
||||
? { text: '', timestamp: '', messages: [] }
|
||||
: { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -3654,6 +3893,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-clear is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3675,6 +3924,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-compact is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3737,8 +3996,11 @@ export function registerSessionRoutes(
|
||||
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');
|
||||
if (session.remote || session.docker || session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Git fetch does not run for remote, Docker or adopted sessions'
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled;
|
||||
@@ -3764,6 +4026,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-resume is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3834,6 +4106,10 @@ export function registerSessionRoutes(
|
||||
if (body.enabled && session.remote) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions');
|
||||
}
|
||||
// An adopted session's folder is the neutral wrapper folder, not the foreign pane's work.
|
||||
if (body.enabled && session.isAdopted) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for adopted sessions');
|
||||
}
|
||||
|
||||
if (body.enabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
@@ -4467,14 +4743,13 @@ export function registerSessionRoutes(
|
||||
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
|
||||
// a lighter version of them, since this is the same server-side authority reached a
|
||||
// different way, not a separate, less-checked path.
|
||||
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
|
||||
// setCustomModel() bookkeeping must be given below. The Session constructor already
|
||||
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
|
||||
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
|
||||
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
|
||||
//
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in): setCustomModel()
|
||||
// merges these over what the Session constructor took, which is how it learns which of
|
||||
// the caller's values the selection replaced. Handing the constructor the merged set
|
||||
// would hide them, and giving setCustomModel() that set would put
|
||||
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
|
||||
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
|
||||
let qsCustomModelLaunchModel: string | undefined;
|
||||
let qsCustomModelSessionId: string | undefined;
|
||||
let qsCustomModelSwapInProgress = false;
|
||||
let qsCustomModelBookkeeping:
|
||||
@@ -4485,6 +4760,7 @@ export function registerSessionRoutes(
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
homeEnvVar?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (customModel) {
|
||||
@@ -4588,9 +4864,7 @@ export function registerSessionRoutes(
|
||||
if (!cmApplied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
|
||||
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
||||
qsCustomModelLaunchModel = cmApplied.launchModel;
|
||||
qsCustomModelBookkeeping = {
|
||||
endpointId: cmEndpoint.id,
|
||||
modelId: customModel.modelId,
|
||||
@@ -4598,6 +4872,7 @@ export function registerSessionRoutes(
|
||||
envKeys: cmApplied.envKeys,
|
||||
configDir: cmApplied.configDir,
|
||||
launchModel: cmApplied.launchModel,
|
||||
homeEnvVar: cmApplied.homeEnvVar,
|
||||
};
|
||||
|
||||
// Actually kick off llama-swap's load now — see the dedicated apply route's own
|
||||
@@ -4630,7 +4905,7 @@ export function registerSessionRoutes(
|
||||
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
envOverrides: qsCustomModelEnvOverrides,
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
remote,
|
||||
@@ -4639,30 +4914,15 @@ export function registerSessionRoutes(
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
parentSessionId: qsParentSessionId,
|
||||
};
|
||||
// Force the custom-model selection's launchModel (pi/omp `custom/<id>`, grok's
|
||||
// `[model.<name>]` block name) onto whichever config field the registry says the
|
||||
// CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel,
|
||||
// which the restart-in-place path already uses, rather than a hardcoded per-CLI
|
||||
// branch here that a CLI landing its injection recipe later would silently miss.
|
||||
if (qsCustomModelLaunchModel !== undefined) {
|
||||
const qsCustomModelField = getCli(mode)?.launch.legacyConfigField;
|
||||
if (qsCustomModelField) {
|
||||
const qsSessionOptionsBag = qsSessionOptions as unknown as Record<string, unknown>;
|
||||
qsSessionOptionsBag[qsCustomModelField] = {
|
||||
...((qsSessionOptionsBag[qsCustomModelField] as Record<string, unknown>) ?? {}),
|
||||
model: qsCustomModelLaunchModel,
|
||||
};
|
||||
} else {
|
||||
qsSessionOptions.model = qsCustomModelLaunchModel;
|
||||
}
|
||||
}
|
||||
const session = new Session(qsSessionOptions);
|
||||
|
||||
// Records the selection for session.customModel/getCustomModelForPersist() and future
|
||||
// clear/switch calls — the actual env vars and launch-model config are already part of
|
||||
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
|
||||
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
|
||||
// tmux IO of its own (see its own doc comment in session.ts).
|
||||
// clear/switch calls, and merges the injected env. The launch below reads both: the
|
||||
// selection's launchModel is forced onto the launch options, never onto the stored
|
||||
// config above, because that config is what a later clear falls back to
|
||||
// (Session._withCustomModelLaunchModel). Nothing has started yet, so this is never a
|
||||
// restart: setCustomModel() is synchronous state, no tmux IO of its own (see its own
|
||||
// doc comment in session.ts).
|
||||
if (qsCustomModelBookkeeping) {
|
||||
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
|
||||
}
|
||||
@@ -5799,6 +6059,13 @@ export function registerSessionRoutes(
|
||||
'Prompt uploads are not supported for remote (SSH) sessions'
|
||||
);
|
||||
}
|
||||
// An adopted session has no workspace of ours: its folder is the neutral `/tmp`, shared with
|
||||
// whatever else runs there, and an agent in a container or on an ssh host could not read it.
|
||||
// The foreign pane's own folder is not ours to write into either.
|
||||
if (session.isAdopted) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for adopted sessions');
|
||||
}
|
||||
|
||||
// The upload is written under session.workingDir (ensureUploadDir below), and
|
||||
// a live session's workspace can sit on a network mount that stopped answering.
|
||||
|
||||
@@ -1185,9 +1185,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 (never a remote session: its
|
||||
// workingDir is a path on the remote host, not on this disk)
|
||||
// workingDir is a path on the remote host, not on this disk; nor an adopted one, whose folder
|
||||
// is the neutral wrapper folder and not the foreign pane's work)
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled && !session.remote) {
|
||||
if (session.imageWatcherEnabled && !session.remote && !session.isAdopted) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-9
@@ -371,6 +371,7 @@ const CodexConfigSchema = z
|
||||
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||
.optional(),
|
||||
dangerouslyBypassApprovals: z.boolean().optional(),
|
||||
networkAccess: z.boolean().optional(),
|
||||
animations: z.boolean().optional(),
|
||||
renderMode: z
|
||||
.enum(['scrollback', 'hybrid'])
|
||||
@@ -867,6 +868,8 @@ const RemoteCommandOverridesSchema = z
|
||||
pi: z.string().min(1).max(300).optional(),
|
||||
grok: z.string().min(1).max(300).optional(),
|
||||
deepseek: z.string().min(1).max(300).optional(),
|
||||
omp: z.string().min(1).max(300).optional(),
|
||||
copilot: z.string().min(1).max(300).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
@@ -878,11 +881,11 @@ const RemoteCommandOverridesSchema = z
|
||||
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
|
||||
const noCommandSubstitution = (s: string) => !s.includes('$(');
|
||||
|
||||
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
|
||||
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
|
||||
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
|
||||
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
|
||||
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
|
||||
// `remotePath`/`identityFile` are shell-escaped, and a remote launch reaches `bash -c`
|
||||
// single-quoted (`bashCArg()` in tmux-manager). They are also pasted into strings the
|
||||
// far end's shells parse, and hosts stored before a launch-quoting change are read with
|
||||
// no schema, so `$` and backtick (and newline/CR/NUL) stay rejected at the boundary as
|
||||
// defense in depth, whatever the current quoting does with them.
|
||||
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
|
||||
|
||||
export const RemoteHostSchema = z.object({
|
||||
@@ -982,10 +985,10 @@ export const RemoteCaseLinkSchema = z.object({
|
||||
//
|
||||
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
|
||||
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
|
||||
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
|
||||
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
|
||||
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
|
||||
// any docker-socket mount are structurally unrepresentable (never accepted).
|
||||
// `containerWorkdir`, and `container` all reach the `bash -c` launch layer, so they
|
||||
// carry NO_SHELL_META (rejects `$`/backtick) exactly like remotePath/identityFile.
|
||||
// `--privileged` and any docker-socket mount are structurally unrepresentable
|
||||
// (never accepted).
|
||||
|
||||
const DockerResourceLimitsSchema = z
|
||||
.object({
|
||||
@@ -1721,6 +1724,7 @@ export const SettingsUpdateSchema = z
|
||||
.optional(),
|
||||
codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(),
|
||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
||||
codexNetworkAccess: z.boolean().optional(),
|
||||
codexAnimationsEnabled: z.boolean().optional(),
|
||||
// Terminal history and retention
|
||||
terminalScrollbackLines: z
|
||||
@@ -2519,6 +2523,27 @@ export const CustomModelSelectionSchema = z.union([
|
||||
}),
|
||||
z.object({ clear: z.literal(true) }),
|
||||
]);
|
||||
/**
|
||||
* Adopt a FOREIGN tmux session (one a human started outside Codeman).
|
||||
*
|
||||
* ⚠️ The body carries ONLY the opaque candidate id from `GET /api/mux/foreign`.
|
||||
* The socket path, session name and host are re-resolved server-side by re-running
|
||||
* discovery, so a browser can never hand the launch chain a path or a session name
|
||||
* to interpolate. That is the same discipline that keeps the docker-adopt and
|
||||
* remote-attach paths free of caller-supplied command fragments.
|
||||
*/
|
||||
export const AdoptForeignSessionSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(64),
|
||||
/** Optional tab name; defaults to the foreign session's own name. */
|
||||
name: z.string().max(128).optional(),
|
||||
/** Include docker locations in the re-resolve (must match the listing call). */
|
||||
docker: z.boolean().optional(),
|
||||
/** Include remote locations in the re-resolve. */
|
||||
remote: z.boolean().optional(),
|
||||
parentSessionId: z.string().max(64).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */
|
||||
export const AgentInspectQuerySchema = z
|
||||
|
||||
+93
-29
@@ -35,6 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { findLiveWorktree } from '../live-worktree.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
|
||||
@@ -104,6 +105,7 @@ import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { clampEnvOverridesForOwner } from '../session-env-clamp.js';
|
||||
import {
|
||||
applyWorkspaceHooks,
|
||||
pruneAgentSessionPreambles,
|
||||
@@ -135,11 +137,14 @@ 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 { guardSessionConfigDirHome } from './routes/session-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 { EditHistorySync, remoteTranscriptIds } from './edit-history-sync.js';
|
||||
import { EditHistoryStore } from '../edit-history-store.js';
|
||||
import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js';
|
||||
import { remoteLocateClaudeTranscript, remoteProbePaths, remoteReadClaudeEditSources } from '../remote-files.js';
|
||||
import { remoteTmuxLocation } from '../tmux-manager.js';
|
||||
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
@@ -356,19 +361,31 @@ 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.
|
||||
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks.
|
||||
* A local session's 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. A remote session sends no hooks: its host is asked over ssh for the transcript
|
||||
* the pane's claude writes, else the stored conversation's (`remoteTranscriptIds`), and is
|
||||
* never woken for it (remote-files.ts).
|
||||
*/
|
||||
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,
|
||||
transcriptPath: (session, storedTranscriptId) =>
|
||||
session.remote
|
||||
? remoteLocateClaudeTranscript(
|
||||
session.remote,
|
||||
remoteTranscriptIds(session, storedTranscriptId),
|
||||
remoteTmuxLocation(session.remote, session.id)
|
||||
)
|
||||
: resolveClaudeTranscript(
|
||||
this.transcriptWatchers.get(session.id)?.getPath(),
|
||||
[session.claudeSessionId || session.id, session.id],
|
||||
session.claudeConfigDir
|
||||
),
|
||||
readSources: (session, mainPath, requests, budget, opts) =>
|
||||
session.remote
|
||||
? remoteReadClaudeEditSources(session.remote, mainPath, requests, budget, opts)
|
||||
: readClaudeEditSources(mainPath, requests, budget, opts),
|
||||
probe: (session, paths) => (session.remote ? remoteProbePaths(session.remote, paths) : probeLocalPaths(paths)),
|
||||
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
|
||||
deleteStore: (sessionId) => EditHistoryStore.delete(sessionId),
|
||||
sweepStores: (keep) => EditHistoryStore.sweep(keep),
|
||||
@@ -552,9 +569,10 @@ export class WebServer extends EventEmitter {
|
||||
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.
|
||||
// An adopted session's folder is the neutral wrapper folder, which its agent need not be able to read.
|
||||
sessionDir: (sessionId) => {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session && !session.remote ? session.workingDir : undefined;
|
||||
return session && !session.remote && !session.isAdopted ? session.workingDir : undefined;
|
||||
},
|
||||
});
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
@@ -1519,7 +1537,8 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private async _rebuildCustomModelEnv(
|
||||
session: Session,
|
||||
saved: CustomModelBookkeeping
|
||||
saved: CustomModelBookkeeping,
|
||||
ownEnv: Record<string, string> = {}
|
||||
): Promise<AppliedCustomModel | undefined> {
|
||||
const entry = getCli(session.mode);
|
||||
if (!entry) return undefined;
|
||||
@@ -1531,19 +1550,21 @@ 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).
|
||||
// Boot recovery omits `workingDir` (no reason to re-answer a trust dialog that already
|
||||
// fired once). The real-home lookup reads the record's own values, since the session's
|
||||
// overrides hold what the previous run injected. `linkRealHomeEntry()` keeps what is
|
||||
// there, so this only decides an entry that is missing, which an own home skips when it
|
||||
// does not hold one. With no own home, or one that does not answer (probed under the
|
||||
// bulk cap, this walks every saved session), a missing entry links from the SERVER's
|
||||
// `DSH_HOME` or `CLAUDE_CONFIG_DIR`, or their defaults (~/.dsh, ~/.claude).
|
||||
return applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
saved.modelId,
|
||||
session.id,
|
||||
resolveCustomModelContextLength(endpoint, saved.modelId)
|
||||
resolveCustomModelContextLength(endpoint, saved.modelId),
|
||||
undefined,
|
||||
await guardSessionConfigDirHome(entry, (envVar) => ownEnv[envVar], {})
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
|
||||
@@ -1559,18 +1580,25 @@ export class WebServer extends EventEmitter {
|
||||
* 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.
|
||||
*
|
||||
* `ownEnv` is always passed, empty for a record saved before it existed: `_envOverrides`
|
||||
* holds what the previous run injected, and only this record says what was the session's
|
||||
* own. It meets the owner's grant again, like every other env override rebuilt from disk.
|
||||
*/
|
||||
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
||||
const applied = await this._rebuildCustomModelEnv(session, saved);
|
||||
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
|
||||
const applied = await this._rebuildCustomModelEnv(session, saved, ownEnv);
|
||||
if (!applied) {
|
||||
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
|
||||
// is what a later clear needs.
|
||||
session.setCustomModel(saved);
|
||||
session.setCustomModel({ ...saved, ownEnv });
|
||||
return;
|
||||
}
|
||||
session.setCustomModel(
|
||||
{
|
||||
...saved,
|
||||
ownEnv,
|
||||
homeEnvVar: applied.homeEnvVar ?? saved.homeEnvVar,
|
||||
// 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,
|
||||
@@ -1590,8 +1618,9 @@ export class WebServer extends EventEmitter {
|
||||
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
|
||||
// separate from the sanitized public attachmentHistory in toState().
|
||||
const attachmentHistory = session.getAttachmentHistoryForPersist();
|
||||
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir,
|
||||
// launch model; never the values) so recovery can restore and later clear it.
|
||||
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir, launch
|
||||
// model, the session's own home path; never an injected value or any other own value)
|
||||
// so recovery can restore and later clear it.
|
||||
const customModel = session.getCustomModelForPersist();
|
||||
const state = {
|
||||
...base,
|
||||
@@ -2179,7 +2208,9 @@ export class WebServer extends EventEmitter {
|
||||
// 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)) {
|
||||
// ⚠️ An ADOPTED session is skipped outright: Ralph is refused for one anyway, and its
|
||||
// `workingDir` is the neutral wrapper folder, not the foreign pane's work.
|
||||
if (!isExternalCliMode(session.mode) && !session.isAdopted) {
|
||||
if (session.remote) {
|
||||
session.ralphTracker.setRemoteWorkingDir(session.workingDir);
|
||||
} else {
|
||||
@@ -2189,8 +2220,10 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// 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) {
|
||||
// disk, so it is skipped there (the per-session and app-wide toggles skip it too). An ADOPTED session
|
||||
// is skipped for the same reason as the Ralph watcher above: nothing in its wrapper folder is the
|
||||
// foreign pane's work.
|
||||
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote && !session.isAdopted) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
|
||||
@@ -2277,6 +2310,9 @@ export class WebServer extends EventEmitter {
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
// ⚠️ A link scanned from an adopted pane was printed by a process on another host or in a
|
||||
// container, while confinement here would be to this host's `/tmp`, the wrapper's folder.
|
||||
if (session.isAdopted) return;
|
||||
const event =
|
||||
source === 'codex-generated'
|
||||
? await registerGeneratedArtifactAttachment({
|
||||
@@ -2967,6 +3003,7 @@ export class WebServer extends EventEmitter {
|
||||
// `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 (paneCwdChanged) void this.refreshLiveWorktree(session);
|
||||
if (session.setPaneExit(getPaneExit(muxName))) {
|
||||
this.persistSessionState(session);
|
||||
} else if (!paneCwdChanged) {
|
||||
@@ -2977,6 +3014,18 @@ export class WebServer extends EventEmitter {
|
||||
this.closeCleanlyExitedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which linked worktree a session's pane moved into (#765). In the background, since
|
||||
* it reads the folder, and only after the directory changed, so an idle pane costs nothing.
|
||||
* A reading newer than the one this lookup started from owns the answer.
|
||||
*/
|
||||
private async refreshLiveWorktree(session: Session): Promise<void> {
|
||||
const cwd = session.paneCwd;
|
||||
const worktree = await findLiveWorktree(session.workingDir, cwd);
|
||||
if (session.paneCwd !== cwd) return;
|
||||
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every session whose agent exited cleanly, through the same
|
||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
||||
@@ -4111,6 +4160,14 @@ export class WebServer extends EventEmitter {
|
||||
// MuxSession.docker; state.json carries SessionState.docker), so recovery
|
||||
// rebuilds the `docker exec` launch instead of a broken local command.
|
||||
docker: muxSession.docker ?? savedState?.docker,
|
||||
// Adoption metadata round-trips for the same reason remote/docker do,
|
||||
// and one more: it is the ONLY thing that marks this session as
|
||||
// wrapping a process Codeman never launched. Dropping it on recovery
|
||||
// silently re-enabled respawn, Ralph and hook-backed waits against
|
||||
// someone else's live tmux session after every server restart:
|
||||
// measured, not hypothetical. The mux record is preferred because it
|
||||
// is what `killSession`'s detach-not-kill guard already reads.
|
||||
adopt: muxSession.adopt ?? savedState?.adopt,
|
||||
owner: recoveredOwner,
|
||||
// Tab lineage survives a restart. It is only decoration, so a parent
|
||||
// that did NOT come back is harmless: the frontend draws an edge only
|
||||
@@ -4394,6 +4451,13 @@ export class WebServer extends EventEmitter {
|
||||
const workspaces = new Set<string>();
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.mode !== 'claude' || session.remote) continue;
|
||||
// ⚠️ An ADOPTED session is not caught by the `remote` check above: its
|
||||
// connection facts live on `adopt.remote` / `adopt.docker`, so a session
|
||||
// wrapping someone's in-container or over-ssh claude looks local here.
|
||||
// Its workspace was never Codeman's to begin with, so installing hooks
|
||||
// there writes `.claude/settings.local.json` into the human's own repo,
|
||||
// on EVERY restart, since this sweep runs from boot recovery.
|
||||
if (session.isAdopted) continue;
|
||||
if (session.docker && !session.docker.hooksEnabled) continue;
|
||||
if (session.workingDir) workspaces.add(session.workingDir);
|
||||
}
|
||||
|
||||
@@ -200,6 +200,18 @@ export interface HookCapabilityOptions {
|
||||
* timeout on every turn.
|
||||
*/
|
||||
deepSeekBridgeUnreachable?: boolean;
|
||||
/**
|
||||
* True when the session is a WRAPPER around a tmux session a human started
|
||||
* outside Codeman.
|
||||
*
|
||||
* This one overrides the mode entirely, and it has to: an adopted session can
|
||||
* be `mode: 'claude'` and still have no hooks, because hooks are installed into
|
||||
* a WORKSPACE at session-create time (`applyWorkspaceHooks`) and we never
|
||||
* created this one. Answering from the mode there would promise `stop` and
|
||||
* `blocked` for a process that can never post either, the exact
|
||||
* infinite-wait-dressed-as-a-timeout this predicate exists to prevent.
|
||||
*/
|
||||
adopted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +246,9 @@ export interface HookCapabilityOptions {
|
||||
* function only about hook SIGNALS.
|
||||
*/
|
||||
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
|
||||
// Checked BEFORE the mode: adoption is about who launched the process, and no
|
||||
// mode can vouch for a workspace Codeman never touched. See `adopted` above.
|
||||
if (options.adopted) return false;
|
||||
// A TRI-state capability, not a boolean, because the three answers are genuinely
|
||||
// different questions — see CliCapabilities.hooks.
|
||||
switch (getCli(mode)?.capabilities.hooks) {
|
||||
@@ -269,10 +284,12 @@ export function sessionHookOptions(session: {
|
||||
deepSeekStatusReporting?: boolean;
|
||||
docker?: unknown;
|
||||
remote?: unknown;
|
||||
adopt?: unknown;
|
||||
}): HookCapabilityOptions {
|
||||
return {
|
||||
deepSeekStatusReporting: session.deepSeekStatusReporting,
|
||||
deepSeekBridgeUnreachable: Boolean(session.docker || session.remote),
|
||||
adopted: Boolean(session.adopt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,8 +359,10 @@ export function resolveWaitSignals(
|
||||
// A dsh session is the one case where the mode is capable and THIS session
|
||||
// is not, so saying "never fire for deepseek sessions" would send the
|
||||
// caller looking for a bug that is really a setting they chose.
|
||||
error:
|
||||
options.mode === 'deepseek'
|
||||
error: options.adopted
|
||||
? `Signal(s) ${rejected.join(', ')} never fire for an adopted session: Codeman did not start it, so it ` +
|
||||
`installed no hooks there. Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`
|
||||
: options.mode === 'deepseek'
|
||||
? options.deepSeekBridgeUnreachable
|
||||
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: it runs in a container or on ` +
|
||||
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
|
||||
|
||||
Reference in new issue
Block a user